diff --git a/microsite/data/plugins/git-release-manager.yaml b/microsite/data/plugins/git-release-manager.yaml new file mode 100644 index 0000000000..7eec6040ac --- /dev/null +++ b/microsite/data/plugins/git-release-manager.yaml @@ -0,0 +1,9 @@ +--- +title: GitHub Release Manager +author: '@Spotify' +authorUrl: https://github.com/spotify +category: Release management +description: Manage releases without having to juggle git commands +documentation: https://github.com/backstage/backstage/tree/master/plugins/git-release-manager +iconUrl: img/git-release-manager-logo.svg +npmPackageName: '@backstage/plugin-git-release-manager' diff --git a/microsite/static/img/git-release-manager-logo.svg b/microsite/static/img/git-release-manager-logo.svg new file mode 100644 index 0000000000..b505d21560 --- /dev/null +++ b/microsite/static/img/git-release-manager-logo.svg @@ -0,0 +1,13 @@ + + + Export + + + + + + + + + + \ No newline at end of file diff --git a/plugins/git-release-manager/.eslintrc.js b/plugins/git-release-manager/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/git-release-manager/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/git-release-manager/README.md b/plugins/git-release-manager/README.md new file mode 100644 index 0000000000..d9e1703781 --- /dev/null +++ b/plugins/git-release-manager/README.md @@ -0,0 +1,59 @@ +# Git Release Manager (GRM) + +## Overview + +`GRM` enables developers to manage their releases without having to juggle git commands. + +Does it build and ship your code? **No**. + +What `GRM` does is manage your Git **[releases](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository)**, building and shipping is entirely up to you as a developer to handle in your CI. + +`GRM` is built with industry standards in mind and the flow is as follows: + +![](./src/features/Info/flow.png) + +> **Git**: The source control system where releases reside in a practical sense. Read more about [Git releases](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository). (Note that this plugin works just as well with any system implementing `Git`.) +> +> **Release Candidate (RC)**: A Git pre-release intended primarily for internal testing +> +> **Release Version**: A Git release intended for end users + +Looking at the flow above, a common release lifecycle could be: + +- User presses **Create Release Candidate** + - `GRM` + 1. Creates a release branch `rc/` + 1. Creates Release Candidate tag `rc-` + 1. Creates a Git prerelease with Release Candidate tag + - Your CI + 1. Detects the new tag by matching the git reference `refs/tags/rc-.*` + 1. Builds and deploys to staging environment for testing +- User presses **Patch** + - `GRM` + 1. The selected commit is cherry-picked to the release branch + 1. The release tag is bumped + 1. Updates Git release's tag and description with the patch's details + - Your CI + 1. Detects the new tag by matching the git reference `refs/tags/(rc|version)-.*` (Release Versions are patchable as well) + 1. Builds and deploys to staging (or production if Release Version) for testing +- User presses **Promote Release Candidate to Release Version** + - `GRM` + 1. Creates Release Version tag `version-` + 1. Promotes the Git release by removing the prerelease flag + - Your CI + 1. Detects the new tag by matching the git reference `refs/tags/version-.*` + 1. Builds and deploys to production for testing + +## Usage + +### Importing + +The plugin exports a single full-page extension `GitReleaseManagerPage`, which one can add to an app like a usual top-level tool on a dedicated route. + +### Configuration + +The plugin is configurable either via props or the select elements on the page. + +If project configuration is provided via props, the select elements are disabled. It is also possible to omit features from the page via props, as well as attaching callbacks for successful executions. + +See the plugin's dev folder (`dev/index.tsx`) to see some examples. diff --git a/plugins/git-release-manager/dev/index.tsx b/plugins/git-release-manager/dev/index.tsx new file mode 100644 index 0000000000..cad8aaa9dc --- /dev/null +++ b/plugins/git-release-manager/dev/index.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { Box, Typography } from '@material-ui/core'; + +import { gitReleaseManagerPlugin, GitReleaseManagerPage } from '../src/plugin'; +import { InfoCardPlus } from '../src/components/InfoCardPlus'; + +createDevApp() + .registerPlugin(gitReleaseManagerPlugin) + .addPage({ + title: 'Dynamic', + path: '/dynamic', + element: ( + + + Dev notes + Configure plugin via select inputs + + + + + ), + }) + .addPage({ + title: 'Static', + path: '/static', + element: ( + + + Dev notes + + Configure plugin statically by passing props to the + `GitHubReleaseManagerPage` component + + + + + + ), + }) + .addPage({ + title: 'Omit', + path: '/omit', + element: ( + + + Dev notes + Each feature can be omitted + Success callbacks can also be added + + + { + // eslint-disable-next-line no-console + console.log( + 'Custom success callback for Create RC', + comparisonUrl, + createdTag, + gitReleaseName, + gitReleaseUrl, + previousTag, + ); + }, + }, + promoteRc: { + omit: true, + }, + patch: { + omit: true, + }, + }} + /> + + ), + }) + .render(); diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json new file mode 100644 index 0000000000..9aca27d1a9 --- /dev/null +++ b/plugins/git-release-manager/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-git-release-manager", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core": "^0.7.8", + "@backstage/integration": "^0.5.1", + "@backstage/theme": "^0.2.7", + "recharts": "^1.8.5", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@octokit/rest": "^18.5.3", + "luxon": "^1.26.0", + "qs": "^6.10.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4", + "react": "^16.13.1" + }, + "devDependencies": { + "@backstage/cli": "^0.6.10", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.10", + "@testing-library/jest-dom": "^5.10.1", + "@types/recharts": "^1.8.15", + "@testing-library/react-hooks": "^3.4.2", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/git-release-manager/src/GitReleaseManager.tsx b/plugins/git-release-manager/src/GitReleaseManager.tsx new file mode 100644 index 0000000000..1b388ffc82 --- /dev/null +++ b/plugins/git-release-manager/src/GitReleaseManager.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { useApi, ContentHeader, Progress } from '@backstage/core'; +import { Box } from '@material-ui/core'; + +import { + ComponentConfig, + ComponentConfigCreateRc, + ComponentConfigPatch, + ComponentConfigPromoteRc, +} from './types/types'; +import { Features } from './features/Features'; +import { gitReleaseManagerApiRef } from './api/serviceApiRef'; +import { InfoCardPlus } from './components/InfoCardPlus'; +import { isProjectValid } from './helpers/isProjectValid'; +import { ProjectContext, Project } from './contexts/ProjectContext'; +import { RepoDetailsForm } from './features/RepoDetailsForm/RepoDetailsForm'; +import { useQueryHandler } from './hooks/useQueryHandler'; +import { UserContext } from './contexts/UserContext'; + +interface GitReleaseManagerProps { + project?: Omit; + features?: { + info?: Pick, 'omit'>; + stats?: Pick, 'omit'>; + createRc?: ComponentConfigCreateRc; + promoteRc?: ComponentConfigPromoteRc; + patch?: ComponentConfigPatch; + }; +} + +export function GitReleaseManager(props: GitReleaseManagerProps) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + + const { getParsedQuery } = useQueryHandler(); + const { parsedQuery } = getParsedQuery(); + const project: Project = isProjectValid(props.project) + ? { + ...props.project, + isProvidedViaProps: true, + } + : { + owner: parsedQuery.owner ?? '', + repo: parsedQuery.repo ?? '', + versioningStrategy: parsedQuery.versioningStrategy ?? 'semver', + isProvidedViaProps: false, + }; + + const userResponse = useAsync(() => + pluginApiClient.getUser({ owner: project.owner, repo: project.repo }), + ); + + if (userResponse.error) { + return {userResponse.error.message}; + } + + if (userResponse.loading) { + return ; + } + + if (!userResponse.value?.user.username) { + return Unable to retrieve username; + } + + const user = userResponse.value.user; + + return ( + + + + + + + + + + {isProjectValid(project) && } + + + + ); +} diff --git a/plugins/git-release-manager/src/api/GitReleaseClient.test.ts b/plugins/git-release-manager/src/api/GitReleaseClient.test.ts new file mode 100644 index 0000000000..1b7cc7876a --- /dev/null +++ b/plugins/git-release-manager/src/api/GitReleaseClient.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader, OAuthApi } from '@backstage/core'; + +import { GitReleaseClient } from './GitReleaseClient'; + +describe('GitReleaseClient', () => { + it('should return the default plugin api client', () => { + const configApi = new ConfigReader({}); + const githubAuthApi: OAuthApi = { + getAccessToken: jest.fn(), + }; + const gitReleaseClient = new GitReleaseClient({ + configApi, + githubAuthApi, + }); + + expect(gitReleaseClient).toMatchInlineSnapshot(` + GitReleaseClient { + "apiBaseUrl": "https://api.github.com", + "createCommit": [Function], + "createRef": [Function], + "createRelease": [Function], + "createTagObject": [Function], + "getAllReleases": [Function], + "getAllTags": [Function], + "getBranch": [Function], + "getCommit": [Function], + "getComparison": [Function], + "getHost": [Function], + "getLatestRelease": [Function], + "getOwners": [Function], + "getRecentCommits": [Function], + "getRepoPath": [Function], + "getRepositories": [Function], + "getRepository": [Function], + "getTag": [Function], + "getUser": [Function], + "githubAuthApi": Object { + "getAccessToken": [MockFunction], + }, + "host": "github.com", + "merge": [Function], + "updateRef": [Function], + "updateRelease": [Function], + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/api/GitReleaseClient.ts b/plugins/git-release-manager/src/api/GitReleaseClient.ts new file mode 100644 index 0000000000..cd178f292a --- /dev/null +++ b/plugins/git-release-manager/src/api/GitReleaseClient.ts @@ -0,0 +1,843 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigApi, OAuthApi } from '@backstage/core'; +import { Octokit } from '@octokit/rest'; +import { GitHubIntegration, ScmIntegrations } from '@backstage/integration'; + +import { DISABLE_CACHE } from '../constants/constants'; +import { Project } from '../contexts/ProjectContext'; +import { UnboxArray, UnboxReturnedPromise } from '../types/helpers'; +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export class GitReleaseClient implements GitReleaseApi { + private readonly githubAuthApi: OAuthApi; + private readonly apiBaseUrl: string; + readonly host: string; + + constructor({ + configApi, + githubAuthApi, + }: { + configApi: ConfigApi; + githubAuthApi: OAuthApi; + }) { + this.githubAuthApi = githubAuthApi; + + const gitHubIntegrations = ScmIntegrations.fromConfig( + configApi, + ).github.list(); + const { host, apiBaseUrl } = this.getGithubIntegrationConfig({ + gitHubIntegrations, + }); + + this.host = host; + this.apiBaseUrl = apiBaseUrl; + } + + private getGithubIntegrationConfig({ + gitHubIntegrations, + }: { + gitHubIntegrations: GitHubIntegration[]; + }) { + const defaultIntegration = gitHubIntegrations.find( + ({ config: { host } }) => host === 'github.com', + ); + const enterpriseIntegration = gitHubIntegrations.find( + ({ config: { host } }) => host !== 'github.com', + ); + + const host = + enterpriseIntegration?.config.host ?? defaultIntegration?.config.host; + const apiBaseUrl = + enterpriseIntegration?.config.apiBaseUrl ?? + defaultIntegration?.config.apiBaseUrl; + + if (!host) { + throw new GitReleaseManagerError( + 'Invalid API configuration: missing host', + ); + } + + if (!apiBaseUrl) { + throw new GitReleaseManagerError( + 'Invalid API configuration: missing apiBaseUrl', + ); + } + + return { + host, + apiBaseUrl, + }; + } + + private async getOctokit() { + const token = await this.githubAuthApi.getAccessToken(['repo']); + + return { + octokit: new Octokit({ + auth: token, + baseUrl: this.apiBaseUrl, + }), + }; + } + + public getHost: GitReleaseApi['getHost'] = () => { + return this.host; + }; + + public getRepoPath: GitReleaseApi['getRepoPath'] = ({ owner, repo }) => { + return `${owner}/${repo}`; + }; + + getOwners: GitReleaseApi['getOwners'] = async () => { + const { octokit } = await this.getOctokit(); + const orgListResponse = await octokit.paginate( + octokit.orgs.listForAuthenticatedUser, + { per_page: 100 }, + ); + + return { + owners: orgListResponse.map(organization => organization.login), + }; + }; + + getRepositories: GitReleaseApi['getRepositories'] = async ({ owner }) => { + const { octokit } = await this.getOctokit(); + + const repositoryResponse = await octokit + .paginate(octokit.repos.listForOrg, { org: owner, per_page: 100 }) + .catch(async error => { + // `owner` is not an org, try listing a user's repositories instead + if (error.status === 404) { + const userRepositoryResponse = await octokit.paginate( + octokit.repos.listForUser, + { username: owner, per_page: 100 }, + ); + return userRepositoryResponse; + } + + throw error; + }); + + return { + repositories: repositoryResponse.map(repository => repository.name), + }; + }; + + getUser: GitReleaseApi['getUser'] = async () => { + const { octokit } = await this.getOctokit(); + const userResponse = await octokit.users.getAuthenticated(); + + return { + user: { + username: userResponse.data.login, + email: userResponse.data.email ?? undefined, + }, + }; + }; + + getRecentCommits: GitReleaseApi['getRecentCommits'] = async ({ + owner, + repo, + releaseBranchName, + }) => { + const { octokit } = await this.getOctokit(); + const recentCommitsResponse = await octokit.repos.listCommits({ + owner, + repo, + ...(releaseBranchName ? { sha: releaseBranchName } : {}), + ...DISABLE_CACHE, + }); + + return { + recentCommits: recentCommitsResponse.data.map(commit => ({ + htmlUrl: commit.html_url, + sha: commit.sha, + author: { + htmlUrl: commit.author?.html_url, + login: commit.author?.login, + }, + commit: { + message: commit.commit.message, + }, + firstParentSha: commit.parents?.[0]?.sha, + })), + }; + }; + + getLatestRelease: GitReleaseApi['getLatestRelease'] = async ({ + owner, + repo, + }) => { + const { octokit } = await this.getOctokit(); + const { data: latestReleases } = await octokit.repos.listReleases({ + owner, + repo, + per_page: 1, + ...DISABLE_CACHE, + }); + + if (latestReleases.length === 0) { + return { + latestRelease: null, + }; + } + + const latestRelease = latestReleases[0]; + + return { + latestRelease: { + targetCommitish: latestRelease.target_commitish, + tagName: latestRelease.tag_name, + prerelease: latestRelease.prerelease, + id: latestRelease.id, + htmlUrl: latestRelease.html_url, + body: latestRelease.body, + }, + }; + }; + + getRepository: GitReleaseApi['getRepository'] = async ({ owner, repo }) => { + const { octokit } = await this.getOctokit(); + const { data: repository } = await octokit.repos.get({ + owner, + repo, + ...DISABLE_CACHE, + }); + + return { + repository: { + pushPermissions: repository.permissions?.push, + defaultBranch: repository.default_branch, + name: repository.name, + }, + }; + }; + + getCommit: GitReleaseApi['getCommit'] = async ({ owner, repo, ref }) => { + const { octokit } = await this.getOctokit(); + const { data: commit } = await octokit.repos.getCommit({ + owner, + repo, + ref, + ...DISABLE_CACHE, + }); + + return { + commit: { + sha: commit.sha, + htmlUrl: commit.html_url, + commit: { + message: commit.commit.message, + }, + createdAt: commit.commit.committer?.date, + }, + }; + }; + + getBranch: GitReleaseApi['getBranch'] = async ({ owner, repo, branch }) => { + const { octokit } = await this.getOctokit(); + + const { data: branchData } = await octokit.repos.getBranch({ + owner, + repo, + branch, + ...DISABLE_CACHE, + }); + + return { + branch: { + name: branchData.name, + links: { + html: branchData._links.html, + }, + commit: { + sha: branchData.commit.sha, + commit: { + tree: { + sha: branchData.commit.commit.tree.sha, + }, + }, + }, + }, + }; + }; + + createRef: GitReleaseApi['createRef'] = async ({ owner, repo, sha, ref }) => { + const { octokit } = await this.getOctokit(); + const createRefResponse = await octokit.git.createRef({ + owner, + repo, + ref, + sha, + }); + + return { + reference: { + ref: createRefResponse.data.ref, + objectSha: createRefResponse.data.object.sha, + }, + }; + }; + + getComparison: GitReleaseApi['getComparison'] = async ({ + owner, + repo, + base, + head, + }) => { + const { octokit } = await this.getOctokit(); + const compareCommitsResponse = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, + }); + + return { + comparison: { + htmlUrl: compareCommitsResponse.data.html_url, + aheadBy: compareCommitsResponse.data.ahead_by, + }, + }; + }; + + createRelease: GitReleaseApi['createRelease'] = async ({ + owner, + repo, + tagName, + name, + targetCommitish, + body, + }) => { + const { octokit } = await this.getOctokit(); + const createReleaseResponse = await octokit.repos.createRelease({ + owner, + repo, + tag_name: tagName, + name: name, + target_commitish: targetCommitish, + body, + prerelease: true, + }); + + return { + release: { + name: createReleaseResponse.data.name, + htmlUrl: createReleaseResponse.data.html_url, + tagName: createReleaseResponse.data.tag_name, + }, + }; + }; + + createTagObject: GitReleaseApi['createTagObject'] = async ({ + owner, + repo, + tag, + object, + taggerName, + taggerEmail, + message, + }) => { + const { octokit } = await this.getOctokit(); + const { data: createdTagObject } = await octokit.git.createTag({ + owner, + repo, + message, + tag, + object, + type: 'commit', + ...(taggerEmail + ? { + tagger: { + date: new Date().toISOString(), + email: taggerEmail, + name: taggerName, + }, + } + : {}), + }); + + return { + tagObject: { + tagName: createdTagObject.tag, + tagSha: createdTagObject.sha, + }, + }; + }; + + createCommit: GitReleaseApi['createCommit'] = async ({ + owner, + repo, + message, + tree, + parents, + }) => { + const { octokit } = await this.getOctokit(); + const { data: commit } = await octokit.git.createCommit({ + owner, + repo, + message, + tree, + parents, + }); + + return { + commit: { + message: commit.message, + sha: commit.sha, + }, + }; + }; + + updateRef: GitReleaseApi['updateRef'] = async ({ + owner, + repo, + ref, + sha, + force, + }) => { + const { octokit } = await this.getOctokit(); + const { data: updatedRef } = await octokit.git.updateRef({ + owner, + repo, + ref, + sha, + force, + }); + + return { + reference: { + ref: updatedRef.ref, + object: { + sha: updatedRef.object.sha, + }, + }, + }; + }; + + merge: GitReleaseApi['merge'] = async ({ owner, repo, base, head }) => { + const { octokit } = await this.getOctokit(); + const { data: merge } = await octokit.repos.merge({ + owner, + repo, + base, + head, + }); + + return { + merge: { + htmlUrl: merge.html_url, + commit: { + message: merge.commit.message, + tree: { + sha: merge.commit.tree.sha, + }, + }, + }, + }; + }; + + updateRelease: GitReleaseApi['updateRelease'] = async ({ + owner, + repo, + releaseId, + tagName, + body, + prerelease, + }) => { + const { octokit } = await this.getOctokit(); + const { data: updatedRelease } = await octokit.repos.updateRelease({ + owner, + repo, + release_id: releaseId, + tag_name: tagName, + body, + prerelease, + }); + + return { + release: { + name: updatedRelease.name, + tagName: updatedRelease.tag_name, + htmlUrl: updatedRelease.html_url, + }, + }; + }; + + getAllTags: GitReleaseApi['getAllTags'] = async ({ owner, repo }) => { + const { octokit } = await this.getOctokit(); + + const tags = await octokit.paginate(octokit.git.listMatchingRefs, { + owner, + repo, + ref: 'tags', + per_page: 100, + ...DISABLE_CACHE, + }); + + return { + tags: tags + .map(tag => ({ + tagName: tag.ref.replace('refs/tags/', ''), + tagSha: tag.object.sha, + tagType: tag.object.type as 'tag' | 'commit', + })) + .reverse(), + }; + }; + + getAllReleases: GitReleaseApi['getAllReleases'] = async ({ owner, repo }) => { + const { octokit } = await this.getOctokit(); + + const releases = await octokit.paginate(octokit.repos.listReleases, { + owner, + repo, + per_page: 100, + ...DISABLE_CACHE, + }); + + return { + releases: releases.map(release => ({ + id: release.id, + name: release.name, + tagName: release.tag_name, + createdAt: release.published_at, + htmlUrl: release.html_url, + })), + }; + }; + + getTag: GitReleaseApi['getTag'] = async ({ owner, repo, tagSha }) => { + const { octokit } = await this.getOctokit(); + const singleTag = await octokit.git.getTag({ + owner, + repo, + tag_sha: tagSha, + }); + + return { + tag: { + date: singleTag.data.tagger.date, + username: singleTag.data.tagger.name, + userEmail: singleTag.data.tagger.email, + objectSha: singleTag.data.object.sha, + }, + }; + }; +} + +type OwnerRepo = { + owner: Project['owner']; + repo: Project['repo']; +}; + +export interface GitReleaseApi { + getHost: () => string; + + getRepoPath: (args: OwnerRepo) => string; + + getOwners: () => Promise<{ + owners: string[]; + }>; + + getRepositories: (args: { + owner: OwnerRepo['owner']; + }) => Promise<{ + repositories: string[]; + }>; + + getUser: ( + args: OwnerRepo, + ) => Promise<{ + user: { + username: string; + email?: string; + }; + }>; + + getRecentCommits: ( + args: { + releaseBranchName?: string; + } & OwnerRepo, + ) => Promise<{ + recentCommits: { + htmlUrl: string; + sha: string; + author: { + htmlUrl?: string; + login?: string; + }; + commit: { + message: string; + }; + firstParentSha?: string; + }[]; + }>; + + getLatestRelease: ( + args: OwnerRepo, + ) => Promise<{ + latestRelease: { + targetCommitish: string; + tagName: string; + prerelease: boolean; + id: number; + htmlUrl: string; + body?: string | null; + } | null; + }>; + + getRepository: ( + args: OwnerRepo, + ) => Promise<{ + repository: { + pushPermissions: boolean | undefined; + defaultBranch: string; + name: string; + }; + }>; + + getCommit: ( + args: { + ref: string; + } & OwnerRepo, + ) => Promise<{ + commit: { + sha: string; + htmlUrl: string; + commit: { + message: string; + }; + createdAt?: string; + }; + }>; + + getBranch: ( + args: { + branch: string; + } & OwnerRepo, + ) => Promise<{ + branch: { + name: string; + links: { + html: string; + }; + commit: { + sha: string; + commit: { + tree: { + sha: string; + }; + }; + }; + }; + }>; + + createRef: ( + args: { + ref: string; + sha: string; + } & OwnerRepo, + ) => Promise<{ + reference: { + ref: string; + objectSha: string; + }; + }>; + + getComparison: ( + args: { + base: string; + head: string; + } & OwnerRepo, + ) => Promise<{ + comparison: { + htmlUrl: string; + aheadBy: number; + }; + }>; + + createRelease: ( + args: { + tagName: string; + name: string; + targetCommitish: string; + body: string; + } & OwnerRepo, + ) => Promise<{ + release: { + name: string | null; + htmlUrl: string; + tagName: string; + }; + }>; + + createTagObject: ( + args: { + tag: string; + taggerEmail?: string; + message: string; + object: string; + taggerName: string; + } & OwnerRepo, + ) => Promise<{ + tagObject: { + tagName: string; + tagSha: string; + }; + }>; + + createCommit: ( + args: { + message: string; + tree: string; + parents: string[]; + } & OwnerRepo, + ) => Promise<{ + commit: { + message: string; + sha: string; + }; + }>; + + updateRef: ( + args: { + sha: string; + ref: string; + force: boolean; + } & OwnerRepo, + ) => Promise<{ + reference: { + ref: string; + object: { + sha: string; + }; + }; + }>; + + merge: ( + args: { + base: string; + head: string; + } & OwnerRepo, + ) => Promise<{ + merge: { + htmlUrl: string; + commit: { + message: string; + tree: { + sha: string; + }; + }; + }; + }>; + + updateRelease: ( + args: { + releaseId: number; + tagName: string; + body?: string; + prerelease?: boolean; + } & OwnerRepo, + ) => Promise<{ + release: { + name: string | null; + tagName: string; + htmlUrl: string; + }; + }>; + + /** + * Get all tags in descending order + */ + getAllTags: ( + args: OwnerRepo, + ) => Promise<{ + tags: Array<{ + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }>; + }>; + + getAllReleases: ( + args: OwnerRepo, + ) => Promise<{ + releases: Array<{ + id: number; + name: string | null; + tagName: string; + createdAt: string | null; + htmlUrl: string; + }>; + }>; + + getTag: ( + args: { + tagSha: string; + } & OwnerRepo, + ) => Promise<{ + tag: { + date: string; + username: string; + userEmail: string; + objectSha: string; + }; + }>; +} + +export type GetOwnersResult = UnboxReturnedPromise; +export type GetRepositoriesResult = UnboxReturnedPromise< + GitReleaseApi['getRepositories'] +>; +export type GetUserResult = UnboxReturnedPromise; +export type GetRecentCommitsResult = UnboxReturnedPromise< + GitReleaseApi['getRecentCommits'] +>; +export type GetRecentCommitsResultSingle = UnboxArray< + GetRecentCommitsResult['recentCommits'] +>; +export type GetLatestReleaseResult = UnboxReturnedPromise< + GitReleaseApi['getLatestRelease'] +>; +export type GetRepositoryResult = UnboxReturnedPromise< + GitReleaseApi['getRepository'] +>; +export type GetCommitResult = UnboxReturnedPromise; +export type GetBranchResult = UnboxReturnedPromise; +export type CreateRefResult = UnboxReturnedPromise; +export type GetComparisonResult = UnboxReturnedPromise< + GitReleaseApi['getComparison'] +>; +export type CreateReleaseResult = UnboxReturnedPromise< + GitReleaseApi['createRelease'] +>; +export type MergeResult = UnboxReturnedPromise; +export type CreateTagObjectResult = UnboxReturnedPromise< + GitReleaseApi['createTagObject'] +>; +export type UpdateReleaseResult = UnboxReturnedPromise< + GitReleaseApi['updateRelease'] +>; +export type GetAllTagsResult = UnboxReturnedPromise< + GitReleaseApi['getAllTags'] +>; +export type GetAllReleasesResult = UnboxReturnedPromise< + GitReleaseApi['getAllReleases'] +>; +export type GetTagResult = UnboxReturnedPromise; diff --git a/plugins/git-release-manager/src/api/serviceApiRef.test.ts b/plugins/git-release-manager/src/api/serviceApiRef.test.ts new file mode 100644 index 0000000000..8313294683 --- /dev/null +++ b/plugins/git-release-manager/src/api/serviceApiRef.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { gitReleaseManagerApiRef } from './serviceApiRef'; + +describe('gitReleaseManagerApiRef', () => { + it('should work', () => { + const result = gitReleaseManagerApiRef; + + expect(result).toMatchInlineSnapshot(` + ApiRefImpl { + "config": Object { + "description": "Used by the Git Release Manager plugin to make requests", + "id": "plugin.git-release-manager.service", + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/api/serviceApiRef.ts b/plugins/git-release-manager/src/api/serviceApiRef.ts new file mode 100644 index 0000000000..7a5c045d40 --- /dev/null +++ b/plugins/git-release-manager/src/api/serviceApiRef.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; + +import { GitReleaseApi } from './GitReleaseClient'; + +export const gitReleaseManagerApiRef = createApiRef({ + id: 'plugin.git-release-manager.service', + description: 'Used by the Git Release Manager plugin to make requests', +}); diff --git a/plugins/git-release-manager/src/components/Differ.test.tsx b/plugins/git-release-manager/src/components/Differ.test.tsx new file mode 100644 index 0000000000..b35773f1f5 --- /dev/null +++ b/plugins/git-release-manager/src/components/Differ.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Differ } from './Differ'; +import { TEST_IDS } from '../test-helpers/test-ids'; +import { + mockReleaseCandidateCalver, + mockReleaseVersionCalver, + mockReleaseVersionSemver, +} from '../test-helpers/test-helpers'; + +describe('Differ', () => { + it('should render icon and `none` for missing current & next', () => { + const { getByTestId, queryByTestId } = render(); + + const icon = getByTestId(TEST_IDS.components.differ.icons.branch); + const current = queryByTestId(TEST_IDS.components.differ.current); + const next = queryByTestId(TEST_IDS.components.differ.next); + + expect(icon).toBeInTheDocument(); + expect(current).toMatchInlineSnapshot(`null`); + expect(next).not.toBeInTheDocument(); + }); + + it('should render icon & current for missing next', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const icon = getByTestId(TEST_IDS.components.differ.icons.branch); + const current = getByTestId(TEST_IDS.components.differ.current); + const next = queryByTestId(TEST_IDS.components.differ.next); + + expect(icon).toBeInTheDocument(); + expect(current.innerHTML).toMatchInlineSnapshot(`"version-1.2.3"`); + expect(next).not.toBeInTheDocument(); + }); + + it('should render icon & current & next (with seperator)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const icon = getByTestId(TEST_IDS.components.differ.icons.branch); + const current = getByTestId(TEST_IDS.components.differ.current); + const next = queryByTestId(TEST_IDS.components.differ.next); + + expect(icon).toBeInTheDocument(); + expect(current.innerHTML).toMatchInlineSnapshot(`"rc-2020.01.01_1"`); + expect(next?.innerHTML).toMatchInlineSnapshot(`"version-2020.01.01_1"`); + }); +}); diff --git a/plugins/git-release-manager/src/components/Differ.tsx b/plugins/git-release-manager/src/components/Differ.tsx new file mode 100644 index 0000000000..e5d64cc914 --- /dev/null +++ b/plugins/git-release-manager/src/components/Differ.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { grey } from '@material-ui/core/colors'; +import CallSplitIcon from '@material-ui/icons/CallSplit'; +import ChatIcon from '@material-ui/icons/Chat'; +import DynamicFeedIcon from '@material-ui/icons/DynamicFeed'; +import GitHubIcon from '@material-ui/icons/GitHub'; +import LocalOfferIcon from '@material-ui/icons/LocalOffer'; + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; +import { TEST_IDS } from '../test-helpers/test-ids'; + +interface DifferProps { + icon: 'tag' | 'branch' | 'github' | 'slack' | 'versioning'; + current?: string; + next?: string | ReactNode; +} + +export const Differ = ({ current, next, icon }: DifferProps) => { + return ( + <> + {icon && ( + + {' '} + + )} + + {!!current && ( + + {current ?? 'None'} + + )} + + {current && next && {' → '}} + + {next && ( + + {next} + + )} + + ); +}; + +interface IconProps { + icon: DifferProps['icon']; +} + +function Icon({ icon }: IconProps) { + switch (icon) { + case 'tag': + return ( + + ); + + case 'branch': + return ( + + ); + + case 'github': + return ( + + ); + + case 'slack': + return ( + + ); + + case 'versioning': + return ( + + ); + + default: + throw new GitReleaseManagerError('Invalid Differ icon'); + } +} diff --git a/plugins/git-release-manager/src/components/Divider.test.tsx b/plugins/git-release-manager/src/components/Divider.test.tsx new file mode 100644 index 0000000000..35e725d2ee --- /dev/null +++ b/plugins/git-release-manager/src/components/Divider.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { TEST_IDS } from '../test-helpers/test-ids'; +import { Divider } from './Divider'; + +describe('Divider', () => { + it('render Divider', () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.components.divider)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/Divider.tsx b/plugins/git-release-manager/src/components/Divider.tsx new file mode 100644 index 0000000000..6be0f4eb3c --- /dev/null +++ b/plugins/git-release-manager/src/components/Divider.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Divider as MaterialDivider } from '@material-ui/core'; + +import { TEST_IDS } from '../test-helpers/test-ids'; + +export const Divider = () => { + return ( + + + + ); +}; diff --git a/plugins/git-release-manager/src/components/InfoCardPlus.test.tsx b/plugins/git-release-manager/src/components/InfoCardPlus.test.tsx new file mode 100644 index 0000000000..01ed7aa4e8 --- /dev/null +++ b/plugins/git-release-manager/src/components/InfoCardPlus.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { TEST_IDS } from '../test-helpers/test-ids'; +import { InfoCardPlus } from './InfoCardPlus'; + +describe('InfoCardPlus', () => { + it('render InfoCardPlus', () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.info.infoFeaturePlus)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/InfoCardPlus.tsx b/plugins/git-release-manager/src/components/InfoCardPlus.tsx new file mode 100644 index 0000000000..fe2da57402 --- /dev/null +++ b/plugins/git-release-manager/src/components/InfoCardPlus.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { InfoCard } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; + +import { TEST_IDS } from '../test-helpers/test-ids'; + +const useStyles = makeStyles(() => ({ + feature: { + marginBottom: '3em', + }, +})); + +export const InfoCardPlus = ({ children }: { children?: React.ReactNode }) => { + const classes = useStyles(); + + return ( +
+ {children} +
+ ); +}; diff --git a/plugins/git-release-manager/src/components/NoLatestRelease.test.tsx b/plugins/git-release-manager/src/components/NoLatestRelease.test.tsx new file mode 100644 index 0000000000..6863109448 --- /dev/null +++ b/plugins/git-release-manager/src/components/NoLatestRelease.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { NoLatestRelease } from './NoLatestRelease'; + +describe('NoLatestRelease', () => { + it('render NoLatestRelease', () => { + const { container } = render(); + + expect(container).toMatchInlineSnapshot(` +
+
+ +
+
+ `); + }); +}); diff --git a/plugins/git-release-manager/src/components/NoLatestRelease.tsx b/plugins/git-release-manager/src/components/NoLatestRelease.tsx new file mode 100644 index 0000000000..e4121b1f4c --- /dev/null +++ b/plugins/git-release-manager/src/components/NoLatestRelease.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert } from '@material-ui/lab'; +import { Box } from '@material-ui/core'; + +import { TEST_IDS } from '../test-helpers/test-ids'; + +export const NoLatestRelease = () => { + return ( + + + Unable to find any Release + + + ); +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.test.tsx new file mode 100644 index 0000000000..a043758209 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { LinearProgressWithLabel, testables } from './LinearProgressWithLabel'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +const { ICONS } = testables; + +describe('LinearProgressWithLabel', () => { + it('should render 50% progress without CompletionEmoji', () => { + const progress = 50; + + const { container, getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.linearProgressWithLabel).getAttribute( + 'style', + ), + ).toContain(`font-size: 141%`); + expect(container.innerHTML).toContain(`${progress}%`); + expect(container.innerHTML).not.toContain(ICONS.SUCCESS); + expect(container.innerHTML).not.toContain(ICONS.FAILURE); + }); + + it('should render 100% progress with CompletionEmoji for success', () => { + const progress = 100; + + const { container, getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.linearProgressWithLabel).getAttribute( + 'style', + ), + ).toContain(`font-size: 157%`); + expect(container.innerHTML).toContain(`${progress}%`); + expect(container.innerHTML).toContain(ICONS.SUCCESS); + expect(container.innerHTML).not.toContain(ICONS.FAILURE); + }); + + it('should render 100% progress with CompletionEmoji for failure if at least one failed response step is present', () => { + const progress = 100; + + const { container } = render( + , + ); + + expect(container.innerHTML).toContain(`${progress}%`); + expect(container.innerHTML).toContain(ICONS.FAILURE); + expect(container.innerHTML).not.toContain(ICONS.SUCCESS); + }); + + it('should round > 100 progress to 100', () => { + const progress = 101; + + const { container } = render( + , + ); + + expect(container.innerHTML).toContain('100%'); + expect(container.innerHTML).toContain(ICONS.SUCCESS); + expect(container.innerHTML).not.toContain(ICONS.FAILURE); + expect(container.innerHTML).not.toContain(`${progress}%`); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.tsx new file mode 100644 index 0000000000..62d14e771e --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, LinearProgress, Typography } from '@material-ui/core'; + +import { ResponseStep } from '../../types/types'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +const STATUSES = { + FAILURE: 'FAILURE', + ONGOING: 'ONGOING', + SUCCESS: 'SUCCESS', +} as const; + +const ICONS = { + SUCCESS: '🚀', + FAILURE: '🔥', +}; + +const getFontSize = (progress: number) => 125 + Math.ceil(progress / Math.PI); + +export function LinearProgressWithLabel(props: { + progress: number; + responseSteps: ResponseStep[]; +}) { + const roundedValue = Math.ceil(props.progress); + const progress = roundedValue < 100 ? roundedValue : 100; + + const failure = props.responseSteps.some( + responseStep => responseStep.icon === 'failure', + ); + + let status: keyof typeof STATUSES = STATUSES.ONGOING; + if (!failure && progress === 100) status = STATUSES.SUCCESS; + if (failure) status = STATUSES.FAILURE; + + const CompletionEmoji = () => { + if (status === STATUSES.ONGOING) return null; + if (status === STATUSES.FAILURE) return {` ${ICONS.FAILURE} `}; + return {` ${ICONS.SUCCESS} `}; + }; + + return ( + + + + + + + + + {`${progress}%`} + + + + + ); +} + +export const testables = { + ICONS, +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.test.tsx new file mode 100644 index 0000000000..5f1c90c8cf --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ResponseStepDialog } from './ResponseStepDialog'; + +jest.mock('../../contexts/RefetchContext', () => ({ + useRefetchContext: () => jest.fn(), +})); + +describe('ResponseStepDialog', () => { + it('should render ResponseStepDialog', () => { + const mockTitle = 'mock_dialog_title'; + const mockResponseStepMessage = 'banana'; + + const { baseElement } = render( + , + ); + + expect(baseElement.innerHTML).toMatch(mockTitle); + expect(baseElement.innerHTML).toMatch(mockResponseStepMessage); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.tsx new file mode 100644 index 0000000000..52ae3e68c0 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Button, + Dialog as MaterialDialog, + DialogActions, + DialogTitle, +} from '@material-ui/core'; +import RefreshIcon from '@material-ui/icons/Refresh'; + +import { LinearProgressWithLabel } from './LinearProgressWithLabel'; +import { ResponseStep } from '../../types/types'; +import { ResponseStepList } from './ResponseStepList'; +import { Transition } from '../Transition'; +import { useRefetchContext } from '../../contexts/RefetchContext'; + +interface DialogProps { + progress: number; + responseSteps: ResponseStep[]; + title: string; +} + +export const ResponseStepDialog = ({ + progress, + responseSteps, + title, +}: DialogProps) => { + const { fetchGitBatchInfo } = useRefetchContext(); + + return ( + + {title} + + + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.test.tsx new file mode 100644 index 0000000000..67fcf634a3 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ResponseStepList } from './ResponseStepList'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +describe('ResponseStepList', () => { + it('should render loading state when loading', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.circularProgress), + ).toBeInTheDocument(); + }); + + it('should render loading state when no responseSteps', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.circularProgress), + ).toBeInTheDocument(); + }); + + it('should render dialog content when loading is completed', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListDialogContent), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.tsx new file mode 100644 index 0000000000..77bcd22957 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { DialogContent, List } from '@material-ui/core'; +import { Progress } from '@backstage/core'; + +import { ResponseStep } from '../../types/types'; +import { ResponseStepListItem } from './ResponseStepListItem'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +interface ResponseStepListProps { + responseSteps: (ResponseStep | undefined)[]; + animationDelay?: number; + loading?: boolean; + closeable?: boolean; + denseList?: boolean; +} + +export const ResponseStepList = ({ + responseSteps, + animationDelay, + loading = false, + denseList = false, + children, +}: PropsWithChildren) => { + return ( + <> + {loading || responseSteps.length === 0 ? ( +
+ +
+ ) : ( + <> + + + {responseSteps.map((responseStep, index) => { + if (!responseStep) { + return null; + } + + return ( + + ); + })} + + + {children} + + + )} + + ); +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.test.tsx new file mode 100644 index 0000000000..f0f185073a --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ResponseStepListItem } from './ResponseStepListItem'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +describe('ResponseStepListItem', () => { + it('should render', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItem), + ).toBeInTheDocument(); + }); + + it('should render success icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconSuccess), + ).toBeInTheDocument(); + }); + + it('should render failure icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconFailure), + ).toBeInTheDocument(); + }); + + it('should render link icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconLink), + ).toBeInTheDocument(); + }); + + it('should render default icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconDefault), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.tsx new file mode 100644 index 0000000000..e1c80691ed --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + colors, + IconButton, + ListItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; + +import { ResponseStep } from '../../types/types'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +interface ResponseStepListItemProps { + responseStep: ResponseStep; + index: number; + animationDelay?: number; +} + +const useStyles = makeStyles({ + item: { + transition: `opacity ${(props: any) => + props.animationDelay <= 0 + ? 0 + : Math.ceil(props.animationDelay / 2)}ms ease-in`, + overflow: 'hidden', + '&:before': { + flex: 'none', + }, + }, + hidden: { + opacity: 0, + height: 0, + minHeight: 0, + }, + shown: { + opacity: 1, + }, +}); + +export const ResponseStepListItem = ({ + responseStep, + animationDelay = 300, +}: ResponseStepListItemProps) => { + const classes = useStyles({ animationDelay }); + + function ItemIcon() { + if (responseStep.icon === 'success') { + return ( + + ); + } + + if (responseStep.icon === 'failure') { + return ( + + ); + } + + if (responseStep.link) { + return ( + { + const newTab = window.open(responseStep.link, '_blank'); + newTab?.focus(); + }} + > + + + ); + } + + return ( + + ); + } + + return ( + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/components/Transition.tsx b/plugins/git-release-manager/src/components/Transition.tsx new file mode 100644 index 0000000000..0c54e0f851 --- /dev/null +++ b/plugins/git-release-manager/src/components/Transition.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { forwardRef, Ref } from 'react'; +import { Slide } from '@material-ui/core'; +import { TransitionProps } from '@material-ui/core/transitions'; + +export const Transition = forwardRef(function Transition( + props: { children?: React.ReactElement } & TransitionProps, + ref: Ref, +) { + return ; +}); diff --git a/plugins/git-release-manager/src/constants/constants.test.ts b/plugins/git-release-manager/src/constants/constants.test.ts new file mode 100644 index 0000000000..759c2c7ab2 --- /dev/null +++ b/plugins/git-release-manager/src/constants/constants.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as constants from './constants'; + +describe('constants', () => { + it('should match snapshot', () => { + expect(constants).toMatchInlineSnapshot(` + Object { + "DISABLE_CACHE": Object { + "headers": Object { + "If-None-Match": "", + }, + }, + "SEMVER_PARTS": Object { + "major": "major", + "minor": "minor", + "patch": "patch", + }, + "TAG_OBJECT_MESSAGE": "Tag generated by your friendly neighborhood Backstage Release Manager", + "VERSIONING_STRATEGIES": Object { + "calver": "calver", + "semver": "semver", + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/constants/constants.ts b/plugins/git-release-manager/src/constants/constants.ts new file mode 100644 index 0000000000..099a5913ca --- /dev/null +++ b/plugins/git-release-manager/src/constants/constants.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const SEMVER_PARTS: { + major: 'major'; + minor: 'minor'; + patch: 'patch'; +} = { + major: 'major', + minor: 'minor', + patch: 'patch', +} as const; + +export const DISABLE_CACHE = { + headers: { + 'If-None-Match': '', + }, +} as const; + +export const VERSIONING_STRATEGIES: { + semver: 'semver'; + calver: 'calver'; +} = { + semver: 'semver', + calver: 'calver', +} as const; + +export const TAG_OBJECT_MESSAGE = + 'Tag generated by your friendly neighborhood Backstage Release Manager'; diff --git a/plugins/git-release-manager/src/contexts/ProjectContext.ts b/plugins/git-release-manager/src/contexts/ProjectContext.ts new file mode 100644 index 0000000000..cc13c5ac77 --- /dev/null +++ b/plugins/git-release-manager/src/contexts/ProjectContext.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { VERSIONING_STRATEGIES } from '../constants/constants'; +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export interface Project { + /** + * Repository's owner (user or organisation) + * + * @example erikengervall + */ + owner: string; + /** + * Repository's name + * + * @example dockest + */ + repo: string; + /** + * Declares the versioning strategy of the project + * + * semver: `1.2.3` (major.minor.patch) + * calver: `2020.01.01_0` (YYYY.0M.0D_patch) + * + * Default: false + */ + versioningStrategy: keyof typeof VERSIONING_STRATEGIES; + /** + * Project props was provided via props + * + * If true, this means select inputs will be disabled + */ + isProvidedViaProps: boolean; +} + +export const ProjectContext = createContext<{ project: Project } | undefined>( + undefined, +); + +export const useProjectContext = () => { + const { project } = useContext(ProjectContext) ?? {}; + + if (!project) { + throw new GitReleaseManagerError('project not found'); + } + + return { + project, + }; +}; diff --git a/plugins/git-release-manager/src/contexts/RefetchContext.ts b/plugins/git-release-manager/src/contexts/RefetchContext.ts new file mode 100644 index 0000000000..9545944a30 --- /dev/null +++ b/plugins/git-release-manager/src/contexts/RefetchContext.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export const RefetchContext = createContext< + { fetchGitBatchInfo: () => any } | undefined +>(undefined); + +export const useRefetchContext = () => { + const refetch = useContext(RefetchContext); + + if (!refetch) { + throw new GitReleaseManagerError('refetch not found'); + } + + return { + fetchGitBatchInfo: refetch.fetchGitBatchInfo, + }; +}; diff --git a/plugins/git-release-manager/src/contexts/UserContext.ts b/plugins/git-release-manager/src/contexts/UserContext.ts new file mode 100644 index 0000000000..664c621d3c --- /dev/null +++ b/plugins/git-release-manager/src/contexts/UserContext.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +interface User { + username: string; + email?: string; +} + +export const UserContext = createContext<{ user: User } | undefined>(undefined); + +export const useUserContext = () => { + const { user } = useContext(UserContext) ?? {}; + + if (!user) { + throw new GitReleaseManagerError('user not found'); + } + + return { + user, + }; +}; diff --git a/plugins/git-release-manager/src/errors/GitReleaseManagerError.ts b/plugins/git-release-manager/src/errors/GitReleaseManagerError.ts new file mode 100644 index 0000000000..3c2b516202 --- /dev/null +++ b/plugins/git-release-manager/src/errors/GitReleaseManagerError.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class GitReleaseManagerError extends Error { + constructor(message: string) { + super(message); + + this.name = 'GitReleaseManagerError'; + } +} diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.test.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.test.tsx new file mode 100644 index 0000000000..b7509cd27b --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockCalverProject, + mockNextGitInfoSemver, + mockReleaseBranch, + mockReleaseCandidateCalver, + mockReleaseVersionCalver, + mockSemverProject, +} from '../../test-helpers/test-helpers'; +import { CreateReleaseCandidate } from './CreateReleaseCandidate'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useCreateReleaseCandidate } from './hooks/useCreateReleaseCandidate'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: jest.fn(() => ({ + project: mockCalverProject, + })), +})); +jest.mock('../../helpers/getReleaseCandidateGitInfo', () => ({ + getReleaseCandidateGitInfo: () => mockNextGitInfoSemver, +})); +jest.mock('./hooks/useCreateReleaseCandidate', () => ({ + useCreateReleaseCandidate: () => + ({ + run: jest.fn(), + responseSteps: [], + progress: 0, + runInvoked: false, + } as ReturnType), +})); + +describe('CreateReleaseCandidate', () => { + it('should display CTA', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.createRc.cta)).toBeInTheDocument(); + }); + + it('should display select element for semver', () => { + (useProjectContext as jest.Mock).mockReturnValue({ + project: mockSemverProject, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.createRc.semverSelect)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.tsx new file mode 100644 index 0000000000..ea074b5fe6 --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Typography, +} from '@material-ui/core'; + +import { + GetBranchResult, + GetLatestReleaseResult, + GetRepositoryResult, +} from '../../api/GitReleaseClient'; +import { ComponentConfigCreateRc } from '../../types/types'; +import { Differ } from '../../components/Differ'; +import { getReleaseCandidateGitInfo } from '../../helpers/getReleaseCandidateGitInfo'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog'; +import { SEMVER_PARTS } from '../../constants/constants'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useCreateReleaseCandidate } from './hooks/useCreateReleaseCandidate'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +interface CreateReleaseCandidateProps { + defaultBranch: GetRepositoryResult['repository']['defaultBranch']; + latestRelease: GetLatestReleaseResult['latestRelease']; + releaseBranch: GetBranchResult['branch'] | null; + onSuccess?: ComponentConfigCreateRc['onSuccess']; +} + +const InfoCardPlusWrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + + Create Release Candidate + + {children} + + ); +}; + +export const CreateReleaseCandidate = ({ + defaultBranch, + latestRelease, + releaseBranch, + onSuccess, +}: CreateReleaseCandidateProps) => { + const { project } = useProjectContext(); + + const [semverBumpLevel, setSemverBumpLevel] = useState<'major' | 'minor'>( + SEMVER_PARTS.minor, + ); + const [releaseCandidateGitInfo, setReleaseCandidateGitInfo] = useState( + getReleaseCandidateGitInfo({ latestRelease, project, semverBumpLevel }), + ); + + useEffect(() => { + setReleaseCandidateGitInfo( + getReleaseCandidateGitInfo({ latestRelease, project, semverBumpLevel }), + ); + }, [semverBumpLevel, setReleaseCandidateGitInfo, latestRelease, project]); + + const { + progress, + responseSteps, + run, + runInvoked, + } = useCreateReleaseCandidate({ + defaultBranch, + latestRelease, + releaseCandidateGitInfo, + project, + onSuccess, + }); + if (responseSteps.length > 0) { + return ( + + ); + } + + if (releaseCandidateGitInfo.error !== undefined) { + return ( + + + {releaseCandidateGitInfo.error.title && ( + {releaseCandidateGitInfo.error.title} + )} + + {releaseCandidateGitInfo.error.subtitle} + + + ); + } + + const tagAlreadyExists = + latestRelease !== null && + latestRelease.tagName === releaseCandidateGitInfo.rcReleaseTag; + const conflictingPreRelease = + latestRelease !== null && latestRelease.prerelease; + + return ( + + {project.versioningStrategy === 'semver' && + latestRelease && + !conflictingPreRelease && ( + + + Select bump severity + + + + + )} + + {conflictingPreRelease || tagAlreadyExists ? ( + <> + {conflictingPreRelease && ( + + + The most recent release is already a Release Candidate + + + )} + + {tagAlreadyExists && ( + + + There's already a tag named{' '} + {releaseCandidateGitInfo.rcReleaseTag} + + + )} + + ) : ( + + + + + + + + + + )} + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx new file mode 100644 index 0000000000..d8703e0278 --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockDefaultBranch, + mockNextGitInfoCalver, + mockReleaseVersionCalver, + mockUser, +} from '../../../test-helpers/test-helpers'; +import { useCreateReleaseCandidate } from './useCreateReleaseCandidate'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); + +describe('useCreateReleaseCandidate', () => { + beforeEach(jest.clearAllMocks); + + it('should return the expected responseSteps and progress', async () => { + const { result } = renderHook(() => + useCreateReleaseCandidate({ + defaultBranch: mockDefaultBranch, + latestRelease: mockReleaseVersionCalver, + releaseCandidateGitInfo: mockNextGitInfoCalver, + project: mockCalverProject, + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(6); + }); + + it('should return the expected responseSteps and progress (with onSuccess)', async () => { + const { result } = renderHook(() => + useCreateReleaseCandidate({ + defaultBranch: mockDefaultBranch, + latestRelease: mockReleaseVersionCalver, + releaseCandidateGitInfo: mockNextGitInfoCalver, + project: mockCalverProject, + onSuccess: jest.fn(), + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.current.responseSteps).toHaveLength(7); + expect(result.current).toMatchInlineSnapshot(` + Object { + "progress": 100, + "responseSteps": Array [ + Object { + "link": "https://latestCommit.html_url", + "message": "Fetched latest commit from \\"mock_defaultBranch\\"", + "secondaryMessage": "with message \\"latestCommit.commit.message\\"", + }, + Object { + "message": "Created Release Branch", + "secondaryMessage": "with ref \\"mock_createRef_ref\\"", + }, + Object { + "message": "Created Tag Object", + "secondaryMessage": "with sha \\"mock_tag_object_sha\\"", + }, + Object { + "message": "Cut Tag Reference", + "secondaryMessage": "with ref \\"mock_createRef_ref\\"", + }, + Object { + "link": "https://mock_compareCommits_html_url", + "message": "Fetched commit comparison", + "secondaryMessage": "rc/2020.01.01_1...rc/2020.01.01_1", + }, + Object { + "link": "https://mock_createRelease_html_url", + "message": "Created Release Candidate \\"mock_createRelease_name\\"", + "secondaryMessage": "with tag \\"rc-2020.01.01_1\\"", + }, + Object { + "icon": "success", + "message": "Success callback successfully called 🚀", + }, + ], + "run": [Function], + "runInvoked": true, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts new file mode 100644 index 0000000000..477e404d78 --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts @@ -0,0 +1,300 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core'; + +import { + GetLatestReleaseResult, + GetRepositoryResult, +} from '../../../api/GitReleaseClient'; +import { CardHook, ComponentConfigCreateRc } from '../../../types/types'; +import { getReleaseCandidateGitInfo } from '../../../helpers/getReleaseCandidateGitInfo'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { Project } from '../../../contexts/ProjectContext'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { useUserContext } from '../../../contexts/UserContext'; + +interface UseCreateReleaseCandidate { + defaultBranch: GetRepositoryResult['repository']['defaultBranch']; + latestRelease: GetLatestReleaseResult['latestRelease']; + releaseCandidateGitInfo: ReturnType; + project: Project; + onSuccess?: ComponentConfigCreateRc['onSuccess']; +} + +export function useCreateReleaseCandidate({ + defaultBranch, + latestRelease, + releaseCandidateGitInfo, + project, + onSuccess, +}: UseCreateReleaseCandidate): CardHook { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); + + if (releaseCandidateGitInfo.error) { + throw new GitReleaseManagerError( + `Unexpected error: ${ + releaseCandidateGitInfo.error.title + ? `${releaseCandidateGitInfo.error.title} (${releaseCandidateGitInfo.error.subtitle})` + : releaseCandidateGitInfo.error.subtitle + }`, + ); + } + + const { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + } = useResponseSteps(); + + /** + * (1) Get the default branch's most recent commit + */ + const [latestCommitRes, run] = useAsyncFn(async () => { + const { commit: latestCommit } = await pluginApiClient + .getCommit({ + owner: project.owner, + repo: project.repo, + ref: defaultBranch, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Fetched latest commit from "${defaultBranch}"`, + secondaryMessage: `with message "${latestCommit.commit.message}"`, + link: latestCommit.htmlUrl, + }); + + return { + latestCommit, + }; + }); + + /** + * (2) Create release branch based on default branch's most recent sha + */ + const releaseBranchRes = useAsync(async () => { + abortIfError(latestCommitRes.error); + if (!latestCommitRes.value) return undefined; + + const { reference: createdReleaseBranch } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + sha: latestCommitRes.value.latestCommit.sha, + ref: `refs/heads/${releaseCandidateGitInfo.rcBranch}`, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Branch "${releaseCandidateGitInfo.rcBranch}" already exists: .../tree/${releaseCandidateGitInfo.rcBranch}`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Release Branch', + secondaryMessage: `with ref "${createdReleaseBranch.ref}"`, + }); + + return { + ...createdReleaseBranch, + }; + }, [latestCommitRes.value, latestCommitRes.error]); + + /** + * (3) Create tag object for our soon-to-be-created annotated tag + */ + const tagObjectRes = useAsync(async () => { + abortIfError(releaseBranchRes.error); + if (!releaseBranchRes.value) return undefined; + + const { tagObject } = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: releaseCandidateGitInfo.rcReleaseTag, + object: releaseBranchRes.value.objectSha, + taggerName: user.username, + taggerEmail: user.email, + message: TAG_OBJECT_MESSAGE, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Tag Object', + secondaryMessage: `with sha "${tagObject.tagSha}"`, + }); + + return { + ...tagObject, + }; + }, [releaseBranchRes.value, releaseBranchRes.error]); + + /** + * (4) Create reference for tag object + */ + const createRcRes = useAsync(async () => { + abortIfError(tagObjectRes.error); + if (!tagObjectRes.value) return undefined; + + const { reference: createdRef } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + ref: `refs/tags/${releaseCandidateGitInfo.rcReleaseTag}`, + sha: tagObjectRes.value.tagSha, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Tag reference "${releaseCandidateGitInfo.rcReleaseTag}" already exists`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Cut Tag Reference', + secondaryMessage: `with ref "${createdRef.ref}"`, + }); + + return { + ...createdRef, + }; + }, [tagObjectRes.value, tagObjectRes.error]); + + /** + * (5) Compose a body for the release + */ + const getComparisonRes = useAsync(async () => { + abortIfError(createRcRes.error); + if (!createRcRes.value) return undefined; + + const previousReleaseBranch = latestRelease + ? latestRelease.targetCommitish + : defaultBranch; + const nextReleaseBranch = releaseCandidateGitInfo.rcBranch; + const { comparison } = await pluginApiClient + .getComparison({ + owner: project.owner, + repo: project.repo, + base: previousReleaseBranch, + head: nextReleaseBranch, + }) + .catch(asyncCatcher); + + const releaseBody = `**Compare** ${comparison.htmlUrl} + +**Ahead by** ${comparison.aheadBy} commits + +**Release branch** ${createRcRes.value.ref} + +--- + +`; + + addStepToResponseSteps({ + message: 'Fetched commit comparison', + secondaryMessage: `${previousReleaseBranch}...${nextReleaseBranch}`, + link: comparison.htmlUrl, + }); + + return { + ...comparison, + releaseBody, + }; + }, [createRcRes.value, createRcRes.error]); + + /** + * (6) Creates the Git Release itself + */ + const createReleaseRes = useAsync(async () => { + abortIfError(getComparisonRes.error); + if (!getComparisonRes.value) return undefined; + + const { release: createReleaseResult } = await pluginApiClient + .createRelease({ + owner: project.owner, + repo: project.repo, + tagName: releaseCandidateGitInfo.rcReleaseTag, + name: releaseCandidateGitInfo.releaseName, + targetCommitish: releaseCandidateGitInfo.rcBranch, + body: getComparisonRes.value.releaseBody, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Created Release Candidate "${createReleaseResult.name}"`, + secondaryMessage: `with tag "${releaseCandidateGitInfo.rcReleaseTag}"`, + link: createReleaseResult.htmlUrl, + }); + + return { + ...createReleaseResult, + }; + }, [getComparisonRes.value, getComparisonRes.error]); + + /** + * (7) Run onSuccess if defined + */ + useAsync(async () => { + if (onSuccess && !!createReleaseRes.value && !!getComparisonRes.value) { + abortIfError(createReleaseRes.error); + + try { + await onSuccess({ + comparisonUrl: getComparisonRes.value.htmlUrl, + createdTag: createReleaseRes.value.tagName, + gitReleaseName: createReleaseRes.value.name, + gitReleaseUrl: createReleaseRes.value.htmlUrl, + previousTag: latestRelease?.tagName, + }); + } catch (error) { + asyncCatcher(error); + } + + addStepToResponseSteps({ + message: 'Success callback successfully called 🚀', + icon: 'success', + }); + } + }, [createReleaseRes.value, createReleaseRes.error]); + + const TOTAL_STEPS = 6 + (!!onSuccess ? 1 : 0); + const [progress, setProgress] = useState(0); + useEffect(() => { + setProgress((responseSteps.length / TOTAL_STEPS) * 100); + }, [TOTAL_STEPS, responseSteps.length]); + + return { + progress, + responseSteps, + run, + runInvoked: Boolean( + latestCommitRes.loading || latestCommitRes.value || latestCommitRes.error, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/Features.test.tsx b/plugins/git-release-manager/src/features/Features.test.tsx new file mode 100644 index 0000000000..7e2428b6bc --- /dev/null +++ b/plugins/git-release-manager/src/features/Features.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, act, waitFor } from '@testing-library/react'; + +import { Features } from './Features'; +import { mockApiClient, mockCalverProject } from '../test-helpers/test-helpers'; +import { TEST_IDS } from '../test-helpers/test-ids'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); + +describe('Features', () => { + it('should omit features omitted via configuration', async () => { + const { getByTestId } = render( + , + ); + + await act(async () => { + await waitFor(() => getByTestId(TEST_IDS.info.info)); + }); + + expect(getByTestId(TEST_IDS.info.info)).toMatchInlineSnapshot(` +
+
+ Terminology +
+

+ + Git + + : The source control system where releases reside in a practical sense. Read more about + + + Git releases + + . +

+

+ + Release Candidate + + : A Git + + prerelease + + intended primarily for internal testing +

+

+ + Release Version + + : A Git release intended for end users +

+
+ `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Features.tsx b/plugins/git-release-manager/src/features/Features.tsx new file mode 100644 index 0000000000..f147a4b9f0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Features.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { ErrorBoundary, Progress, useApi } from '@backstage/core'; + +import { CreateReleaseCandidate } from './CreateReleaseCandidate/CreateReleaseCandidate'; +import { GitReleaseManager } from '../GitReleaseManager'; +import { gitReleaseManagerApiRef } from '../api/serviceApiRef'; +import { Info } from './Info/Info'; +import { Patch } from './Patch/Patch'; +import { PromoteRc } from './PromoteRc/PromoteRc'; +import { RefetchContext } from '../contexts/RefetchContext'; +import { useGetGitBatchInfo } from '../hooks/useGetGitBatchInfo'; +import { useProjectContext } from '../contexts/ProjectContext'; +import { useVersioningStrategyMatchesRepoTags } from '../hooks/useVersioningStrategyMatchesRepoTags'; +import { validateTagName } from '../helpers/tagParts/validateTagName'; + +export function Features({ + features, +}: { + features: ComponentProps['features']; +}) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const { gitBatchInfo, fetchGitBatchInfo } = useGetGitBatchInfo({ + pluginApiClient, + project, + }); + + const { versioningStrategyMatches } = useVersioningStrategyMatchesRepoTags({ + latestReleaseTagName: gitBatchInfo.value?.latestRelease?.tagName, + project, + repositoryName: gitBatchInfo.value?.repository.name, + }); + + if (gitBatchInfo.error) { + return ( + + Error occured while fetching information for "{project.owner}/ + {project.repo}" ({gitBatchInfo.error.message}) + + ); + } + + if (gitBatchInfo.loading) { + return ; + } + + if (gitBatchInfo.value === undefined) { + return Failed to fetch latest Git release; + } + + if (!gitBatchInfo.value.repository.pushPermissions) { + return ( + + You lack push permissions for repository "{project.owner}/{project.repo} + " + + ); + } + + const { tagNameError } = validateTagName({ + project, + tagName: gitBatchInfo.value.latestRelease?.tagName, + }); + if (tagNameError) { + return ( + + {tagNameError.title && {tagNameError.title}} + {tagNameError.subtitle} + + ); + } + + return ( + + + {gitBatchInfo.value.latestRelease && !versioningStrategyMatches && ( + + Versioning mismatch, expected {project.versioningStrategy} version, + got "{gitBatchInfo.value.latestRelease.tagName}" + + )} + + {!gitBatchInfo.value.latestRelease && ( + + This repository doesn't have any releases yet + + )} + + {!gitBatchInfo.value.releaseBranch && ( + + This repository doesn't have any release branches + + )} + + {!features?.info?.omit && ( + + )} + + {!features?.createRc?.omit && ( + + )} + + {!features?.promoteRc?.omit && ( + + )} + + {!features?.patch?.omit && ( + + )} + + + ); +} diff --git a/plugins/git-release-manager/src/features/Info/Info.test.tsx b/plugins/git-release-manager/src/features/Info/Info.test.tsx new file mode 100644 index 0000000000..4bff92b6ab --- /dev/null +++ b/plugins/git-release-manager/src/features/Info/Info.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockCalverProject, + mockReleaseBranch, + mockReleaseCandidateCalver, +} from '../../test-helpers/test-helpers'; +import { Info } from './Info'; + +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); + +describe('Info', () => { + it('should return early if no latestRelease exists', async () => { + const { findByText } = render( + , + ); + + expect(await findByText(mockReleaseBranch.name)).toMatchInlineSnapshot(` + + rc/1.2.3 + + `); + + expect( + await findByText(`${mockCalverProject.owner}/${mockCalverProject.repo}`), + ).toMatchInlineSnapshot(` + + mock_owner/mock_repo + + `); + + expect(await findByText(mockCalverProject.versioningStrategy)) + .toMatchInlineSnapshot(` + + calver + + `); + + expect(await findByText(mockReleaseCandidateCalver.tagName)) + .toMatchInlineSnapshot(` + + rc-2020.01.01_1 + + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Info/Info.tsx b/plugins/git-release-manager/src/features/Info/Info.tsx new file mode 100644 index 0000000000..670452a032 --- /dev/null +++ b/plugins/git-release-manager/src/features/Info/Info.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Typography, Button, Box } from '@material-ui/core'; +import BarChartIcon from '@material-ui/icons/BarChart'; + +import { + GetBranchResult, + GetLatestReleaseResult, +} from '../../api/GitReleaseClient'; +import { Differ } from '../../components/Differ'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { Stats } from '../Stats/Stats'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import flowImage from './flow.png'; +import { Link } from '@backstage/core'; + +interface InfoCardProps { + releaseBranch: GetBranchResult['branch'] | null; + latestRelease: GetLatestReleaseResult['latestRelease']; + statsEnabled: boolean; +} + +export const Info = ({ + releaseBranch, + latestRelease, + statsEnabled, +}: InfoCardProps) => { + const { project } = useProjectContext(); + const [showStats, setShowStats] = useState(false); + + return ( + + + Terminology + + + Git: The source control system where releases reside + in a practical sense. Read more about{' '} + + Git releases + + . + + + + Release Candidate: A Git prerelease intended + primarily for internal testing + + + + Release Version: A Git release intended for end users + + + + + Flow + + + + Git Release Manager is built with a specific flow in mind. For + example, it assumes your project is configured to react to tags + prefixed with rc or version. + + + + + Here's an overview of the flow: + + + flow + + + + Details + + + Repository:{' '} + + + + + Versioning strategy:{' '} + + + + + Latest release branch:{' '} + + + + + Latest release: + + + + {statsEnabled && ( + + + + {showStats && } + + )} + + ); +}; diff --git a/plugins/git-release-manager/src/features/Info/flow.png b/plugins/git-release-manager/src/features/Info/flow.png new file mode 100644 index 0000000000..e70df0d7fa Binary files /dev/null and b/plugins/git-release-manager/src/features/Info/flow.png differ diff --git a/plugins/git-release-manager/src/features/Patch/Patch.test.tsx b/plugins/git-release-manager/src/features/Patch/Patch.test.tsx new file mode 100644 index 0000000000..458861c802 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/Patch.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockReleaseBranch, + mockCalverProject, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { Patch } from './Patch'; + +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); + +describe('Patch', () => { + it('should return early if no latestRelease exists', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.noLatestRelease), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/Patch/Patch.tsx b/plugins/git-release-manager/src/features/Patch/Patch.tsx new file mode 100644 index 0000000000..9ccb061696 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/Patch.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Typography, Box } from '@material-ui/core'; +import { Alert, AlertTitle } from '@material-ui/lab'; + +import { + GetBranchResult, + GetLatestReleaseResult, +} from '../../api/GitReleaseClient'; +import { ComponentConfigPatch } from '../../types/types'; +import { getBumpedTag } from '../../helpers/getBumpedTag'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { NoLatestRelease } from '../../components/NoLatestRelease'; +import { PatchBody } from './PatchBody'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +interface PatchProps { + latestRelease: GetLatestReleaseResult['latestRelease']; + releaseBranch: GetBranchResult['branch'] | null; + onSuccess?: ComponentConfigPatch['onSuccess']; +} + +export const Patch = ({ + latestRelease, + releaseBranch, + onSuccess, +}: PatchProps) => { + return ( + + + + Patch Release {latestRelease?.prerelease ? 'Candidate' : 'Version'} + + + + + + ); +}; + +function BodyWrapper({ latestRelease, releaseBranch, onSuccess }: PatchProps) { + const { project } = useProjectContext(); + + if (latestRelease === null) { + return ; + } + + if (releaseBranch === null) { + return ; + } + + const bumpedTag = getBumpedTag({ + project, + tag: latestRelease.tagName, + bumpLevel: 'patch', + }); + + if (bumpedTag.error !== undefined) { + return ( + + {bumpedTag.error.title && ( + {bumpedTag.error.title} + )} + + {bumpedTag.error.subtitle} + + ); + } + + return ( + + ); +} diff --git a/plugins/git-release-manager/src/features/Patch/PatchBody.test.tsx b/plugins/git-release-manager/src/features/Patch/PatchBody.test.tsx new file mode 100644 index 0000000000..e9dc978845 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/PatchBody.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { + mockApiClient, + mockBumpedTag, + mockCalverProject, + mockReleaseBranch, + mockReleaseCandidateCalver, + mockReleaseVersionCalver, + mockTagParts, +} from '../../test-helpers/test-helpers'; +import { PatchBody } from './PatchBody'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); +jest.mock('./hooks/usePatch', () => ({ + usePatch: () => ({ + run: jest.fn(), + responseSteps: [], + progress: 0, + }), +})); + +describe('PatchBody', () => { + beforeEach(jest.clearAllMocks); + + it('should render error', async () => { + (mockApiClient.getRecentCommits as jest.Mock).mockImplementationOnce(() => { + throw new Error('banana'); + }); + + const { getByTestId } = render( + , + ); + + 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( + , + ); + + expect(getByTestId(TEST_IDS.patch.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.patch.notPrerelease)); + + expect(getByTestId(TEST_IDS.patch.notPrerelease)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/Patch/PatchBody.tsx b/plugins/git-release-manager/src/features/Patch/PatchBody.tsx new file mode 100644 index 0000000000..6f0f88ae46 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/PatchBody.tsx @@ -0,0 +1,314 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { useAsync } from 'react-use'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { + Box, + Button, + Checkbox, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Paper, + Typography, +} from '@material-ui/core'; +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import { Link, Progress, useApi } from '@backstage/core'; + +import { + GetBranchResult, + GetLatestReleaseResult, +} from '../../api/GitReleaseClient'; +import { CalverTagParts } from '../../helpers/tagParts/getCalverTagParts'; +import { ComponentConfigPatch } from '../../types/types'; +import { Differ } from '../../components/Differ'; +import { getPatchCommitSuffix } from './helpers/getPatchCommitSuffix'; +import { gitReleaseManagerApiRef } from '../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../errors/GitReleaseManagerError'; +import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog'; +import { SemverTagParts } from '../../helpers/tagParts/getSemverTagParts'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { usePatch } from './hooks/usePatch'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +interface PatchBodyProps { + bumpedTag: string; + latestRelease: NonNullable; + releaseBranch: GetBranchResult['branch']; + onSuccess?: ComponentConfigPatch['onSuccess']; + tagParts: NonNullable; +} + +export const PatchBody = ({ + bumpedTag, + latestRelease, + releaseBranch, + onSuccess, + tagParts, +}: PatchBodyProps) => { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const [checkedCommitIndex, setCheckedCommitIndex] = useState(-1); + + const gitDataResponse = useAsync(async () => { + const [ + { recentCommits: recentCommitsOnDefaultBranch }, + { recentCommits: recentCommitsOnReleaseBranch }, + ] = await Promise.all([ + pluginApiClient.getRecentCommits({ + owner: project.owner, + repo: project.repo, + }), + pluginApiClient.getRecentCommits({ + owner: project.owner, + repo: project.repo, + releaseBranchName: releaseBranch.name, + }), + ]); + + return { + recentCommitsOnDefaultBranch, + recentCommitsOnReleaseBranch, + }; + }); + + const { progress, responseSteps, run, runInvoked } = usePatch({ + bumpedTag, + latestRelease, + project, + tagParts, + onSuccess, + }); + + if (responseSteps.length > 0) { + return ( + + ); + } + + if (gitDataResponse.error) { + return ( + + Unexpected error: {gitDataResponse.error.message} + + ); + } + + if (gitDataResponse.loading) { + return ( + + + + ); + } + + function Description() { + return ( + <> + {!latestRelease.prerelease && ( + + + + The current Git release is a Release Version + + It's still possible to patch it, but be extra mindful of changes + + + )} + + + + + + + + ); + } + + function CommitList() { + if (!gitDataResponse.value?.recentCommitsOnDefaultBranch) { + return null; + } + + return ( + + {gitDataResponse.value.recentCommitsOnDefaultBranch.map( + (commit, index) => { + // FIXME: Performance improvement opportunity: Convert to object lookup + const commitExistsOnReleaseBranch = !!gitDataResponse.value?.recentCommitsOnReleaseBranch.find( + releaseBranchCommit => + releaseBranchCommit.sha === commit.sha || + // The selected patch commit's sha is included in the commit message, + // which means it's part of a previous patch + releaseBranchCommit.commit.message.includes( + getPatchCommitSuffix({ commitSha: commit.sha }), + ), + ); + const hasNoParent = !commit.firstParentSha; + + return ( +
+ {commitExistsOnReleaseBranch && ( + + {' '} + Already exists on {releaseBranch?.name} + + )} + + { + if (index === checkedCommitIndex) { + setCheckedCommitIndex(-1); + } else { + setCheckedCommitIndex(index); + } + }} + > + + + + + + + {commit.sha} + {' '} + {commit.author.htmlUrl && ( + + @{commit.author.login} + + )} + + } + /> + + + { + const repoPath = pluginApiClient.getRepoPath({ + owner: project.owner, + repo: project.repo, + }); + const host = pluginApiClient.getHost(); + + const newTab = window.open( + `https://${host}/${repoPath}/compare/${releaseBranch?.name}...${commit.sha}`, + '_blank', + ); + newTab?.focus(); + }} + > + + + + +
+ ); + }, + )} +
+ ); + } + + return ( + + + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/Patch/helpers/getPatchCommitSuffix.ts b/plugins/git-release-manager/src/features/Patch/helpers/getPatchCommitSuffix.ts new file mode 100644 index 0000000000..09b867fd25 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/helpers/getPatchCommitSuffix.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const getPatchCommitSuffix = ({ commitSha }: { commitSha: string }) => { + return `[Backstage patch ${commitSha}]`; +}; diff --git a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts new file mode 100644 index 0000000000..95f3e787fd --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { + mockApiClient, + mockBumpedTag, + mockCalverProject, + mockReleaseVersionCalver, + mockSelectedPatchCommit, + mockTagParts, + mockUser, +} from '../../../test-helpers/test-helpers'; +import { usePatch } from './usePatch'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); + +describe('patch', () => { + beforeEach(jest.clearAllMocks); + + it('should return the expected responseSteps and progress', async () => { + const { result } = renderHook(() => + usePatch({ + bumpedTag: mockBumpedTag, + latestRelease: mockReleaseVersionCalver, + project: mockCalverProject, + tagParts: mockTagParts, + }), + ); + + await act(async () => { + await waitFor(() => result.current.run(mockSelectedPatchCommit)); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(9); + }); + + it('should return the expected responseSteps and progress (with onSuccess)', async () => { + const { result } = renderHook(() => + usePatch({ + bumpedTag: mockBumpedTag, + latestRelease: mockReleaseVersionCalver, + project: mockCalverProject, + tagParts: mockTagParts, + onSuccess: jest.fn(), + }), + ); + + await act(async () => { + await waitFor(() => result.current.run(mockSelectedPatchCommit)); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(10); + expect(result.current).toMatchInlineSnapshot(` + Object { + "progress": 100, + "responseSteps": Array [ + Object { + "link": "https://mock_branch_links_html", + "message": "Fetched release branch \\"rc/1.2.3\\"", + }, + Object { + "message": "Created temporary commit", + "secondaryMessage": "with message \\"mock_commit_message\\"", + }, + Object { + "message": "Forced branch \\"rc/2020.01.01_1\\" to temporary commit \\"mock_commit_sha\\"", + }, + Object { + "link": "https://mock_merge_html_url", + "message": "Merged temporary commit into \\"rc/2020.01.01_1\\"", + "secondaryMessage": "with message \\"mock_merge_commit_message\\"", + }, + Object { + "message": "Cherry-picked patch commit to \\"mock_branch_commit_sha\\"", + "secondaryMessage": "with message \\"mock_commit_message\\"", + }, + Object { + "message": "Updated reference \\"mock_update_ref_ref\\"", + }, + Object { + "message": "Created new tag object", + "secondaryMessage": "with name \\"mock_tag_object_tag\\"", + }, + Object { + "message": "Created new reference \\"mock_createRef_ref\\"", + "secondaryMessage": "for tag object \\"mock_tag_object_tag\\"", + }, + Object { + "link": "https://mock_update_release_html_url", + "message": "Updated release \\"mock_update_release_name\\"", + "secondaryMessage": "with tag mock_update_release_tag_name", + }, + Object { + "icon": "success", + "message": "Success callback successfully called 🚀", + }, + ], + "run": [Function], + "runInvoked": true, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts new file mode 100644 index 0000000000..506f218842 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts @@ -0,0 +1,374 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core'; + +import { + GetLatestReleaseResult, + GetRecentCommitsResultSingle, +} from '../../../api/GitReleaseClient'; +import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts'; +import { ComponentConfigPatch, CardHook } from '../../../types/types'; +import { getPatchCommitSuffix } from '../helpers/getPatchCommitSuffix'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { Project } from '../../../contexts/ProjectContext'; +import { SemverTagParts } from '../../../helpers/tagParts/getSemverTagParts'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { useUserContext } from '../../../contexts/UserContext'; + +interface Patch { + bumpedTag: string; + latestRelease: NonNullable; + project: Project; + tagParts: NonNullable; + onSuccess?: ComponentConfigPatch['onSuccess']; +} + +// Inspiration: https://stackoverflow.com/questions/53859199/how-to-cherry-pick-through-githubs-api +export function usePatch({ + bumpedTag, + latestRelease, + project, + tagParts, + onSuccess, +}: Patch): CardHook { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); + const { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + } = useResponseSteps(); + + const releaseBranchName = latestRelease.targetCommitish; + + /** + * (1) Here is the branch we want to cherry-pick to: + * > branch = GET /repos/$owner/$repo/branches/$branchName + * > branchSha = branch.commit.sha + * > branchTree = branch.commit.commit.tree.sha + */ + const [releaseBranchRes, run] = useAsyncFn( + async (selectedPatchCommit: GetRecentCommitsResultSingle) => { + const { branch: releaseBranch } = await pluginApiClient + .getBranch({ + owner: project.owner, + repo: project.repo, + branch: releaseBranchName, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Fetched release branch "${releaseBranch.name}"`, + link: releaseBranch.links.html, + }); + + return { + releaseBranch, + selectedPatchCommit, + }; + }, + ); + + /** + * (2) Create a temporary commit on the branch, which extends as a sibling of + * the commit we want but contains the current tree of the target branch: + * > parentSha = commit.parents.head // first parent -- there should only be one + * > tempCommit = POST /repos/$owner/$repo/git/commits { "message": "temp", "tree": branchTree, "parents": [parentSha] } + */ + const tempCommitRes = useAsync(async () => { + abortIfError(releaseBranchRes.error); + if (!releaseBranchRes.value) return undefined; + + const { commit: tempCommit } = await pluginApiClient + .createCommit({ + owner: project.owner, + repo: project.repo, + message: `Temporary commit for patch ${tagParts.patch}`, + parents: [ + releaseBranchRes.value.selectedPatchCommit.firstParentSha ?? '', + ], + tree: releaseBranchRes.value.releaseBranch.commit.commit.tree.sha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created temporary commit', + secondaryMessage: `with message "${tempCommit.message}"`, + }); + + return { + ...tempCommit, + }; + }, [releaseBranchRes.value, releaseBranchRes.error]); + + /** + * (3) Now temporarily force the branch over to that commit: + * > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = tempCommit.sha, force = true } + */ + const forceBranchRes = useAsync(async () => { + abortIfError(tempCommitRes.error); + if (!tempCommitRes.value) return undefined; + + await pluginApiClient + .updateRef({ + owner: project.owner, + repo: project.repo, + sha: tempCommitRes.value.sha, + ref: `heads/${releaseBranchName}`, + force: true, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Forced branch "${releaseBranchName}" to temporary commit "${tempCommitRes.value.sha}"`, + }); + + return { + trigger: 'next step 🚀 ', + }; + }, [tempCommitRes.value, tempCommitRes.error]); + + /** + * (4) Merge the commit we want into this mess: + * > merge = POST /repos/$owner/$repo/merges { "base": branchName, "head": commit.sha } + */ + const mergeRes = useAsync(async () => { + abortIfError(forceBranchRes.error); + if (!forceBranchRes.value || !releaseBranchRes.value) return undefined; + + const { merge } = await pluginApiClient + .merge({ + owner: project.owner, + repo: project.repo, + base: releaseBranchName, + head: releaseBranchRes.value.selectedPatchCommit.sha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Merged temporary commit into "${releaseBranchName}"`, + secondaryMessage: `with message "${merge.commit.message}"`, + link: merge.htmlUrl, + }); + + return { + ...merge, + }; + }, [forceBranchRes.value, forceBranchRes.error]); + + /** + * (5) Now that we know what the tree should be, create the cherry-pick commit. + * Note that branchSha is the original from up at the top. + * > cherry = POST /repos/$owner/$repo/git/commits { "message": "looks good!", "tree": mergeTree, "parents": [branchSha] } + */ + const cherryPickRes = useAsync(async () => { + abortIfError(mergeRes.error); + if (!mergeRes.value || !releaseBranchRes.value) return undefined; + + const releaseBranchSha = releaseBranchRes.value.releaseBranch.commit.sha; + const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit; + + const { commit: cherryPickCommit } = await pluginApiClient.createCommit({ + owner: project.owner, + repo: project.repo, + message: `[patch ${bumpedTag}] ${selectedPatchCommit.commit.message} + + ${getPatchCommitSuffix({ + commitSha: selectedPatchCommit.sha, + })}`, + parents: [releaseBranchSha], + tree: mergeRes.value.commit.tree.sha, + }); + + addStepToResponseSteps({ + message: `Cherry-picked patch commit to "${releaseBranchSha}"`, + secondaryMessage: `with message "${cherryPickCommit.message}"`, + }); + + return { + ...cherryPickCommit, + }; + }, [mergeRes.value, mergeRes.error]); + + /** + * (6) Replace the temp commit with the real commit: + * > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = cherry.sha, force = true } + */ + const updatedRefRes = useAsync(async () => { + abortIfError(cherryPickRes.error); + if (!cherryPickRes.value) return undefined; + + const { reference: updatedReference } = await pluginApiClient + .updateRef({ + owner: project.owner, + repo: project.repo, + ref: `heads/${releaseBranchName}`, + sha: cherryPickRes.value.sha, + force: true, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Updated reference "${updatedReference.ref}"`, + }); + + return { + ...updatedReference, + }; + }, [cherryPickRes.value, cherryPickRes.error]); + + /** + * (7) Create tag object: https://developer.github.com/v3/git/tags/#create-a-tag-object + * > POST /repos/:owner/:repo/git/tags + */ + const createdTagObjRes = useAsync(async () => { + abortIfError(updatedRefRes.error); + if (!updatedRefRes.value) return undefined; + + const { tagObject } = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: bumpedTag, + object: updatedRefRes.value.object.sha, + message: TAG_OBJECT_MESSAGE, + taggerName: user.username, + taggerEmail: user.email, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created new tag object', + secondaryMessage: `with name "${tagObject.tagName}"`, + }); + + return { + ...tagObject, + }; + }, [updatedRefRes.value, updatedRefRes.error]); + + /** + * (8) Create a reference: https://developer.github.com/v3/git/refs/#create-a-reference + * > POST /repos/:owner/:repo/git/refs + */ + const createdReferenceRes = useAsync(async () => { + abortIfError(createdTagObjRes.error); + if (!createdTagObjRes.value) return undefined; + + const { reference } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + ref: `refs/tags/${bumpedTag}`, + sha: createdTagObjRes.value.tagSha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Created new reference "${reference.ref}"`, + secondaryMessage: `for tag object "${createdTagObjRes.value.tagName}"`, + }); + + return { + ...reference, + }; + }, [createdTagObjRes.value, createdTagObjRes.error]); + + /** + * (9) Update release + */ + const updatedReleaseRes = useAsync(async () => { + abortIfError(createdReferenceRes.error); + if (!createdReferenceRes.value || !releaseBranchRes.value) return undefined; + + const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit; + + const { release } = await pluginApiClient + .updateRelease({ + owner: project.owner, + repo: project.repo, + releaseId: latestRelease.id, + tagName: bumpedTag, + body: `${latestRelease.body} + +#### [Patch ${tagParts.patch}](${selectedPatchCommit.htmlUrl}) + +${selectedPatchCommit.commit.message}`, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Updated release "${release.name}"`, + secondaryMessage: `with tag ${release.tagName}`, + link: release.htmlUrl, + }); + + return { + ...release, + }; + }, [createdReferenceRes.value, createdReferenceRes.error]); + + /** + * (10) Run onSuccess if defined + */ + useAsync(async () => { + if (!onSuccess) return; + abortIfError(updatedReleaseRes.error); + + if (!updatedReleaseRes.value || !releaseBranchRes.value) return; + + try { + await onSuccess?.({ + updatedReleaseUrl: updatedReleaseRes.value.htmlUrl, + updatedReleaseName: updatedReleaseRes.value.name, + previousTag: latestRelease.tagName, + patchedTag: updatedReleaseRes.value.tagName, + patchCommitUrl: releaseBranchRes.value.selectedPatchCommit.htmlUrl, + patchCommitMessage: + releaseBranchRes.value.selectedPatchCommit.commit.message, + }); + } catch (error) { + asyncCatcher(error); + } + + addStepToResponseSteps({ + message: 'Success callback successfully called 🚀', + icon: 'success', + }); + }, [updatedReleaseRes.value, updatedReleaseRes.error]); + + const TOTAL_STEPS = 9 + (!!onSuccess ? 1 : 0); + const [progress, setProgress] = useState(0); + useEffect(() => { + setProgress((responseSteps.length / TOTAL_STEPS) * 100); + }, [TOTAL_STEPS, responseSteps.length]); + + return { + progress, + responseSteps, + run, + runInvoked: Boolean( + releaseBranchRes.loading || + releaseBranchRes.value || + releaseBranchRes.error, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.test.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.test.tsx new file mode 100644 index 0000000000..089b626da3 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockReleaseCandidateCalver, + mockReleaseVersionCalver, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { PromoteRc } from './PromoteRc'; + +jest.mock('./PromoteRcBody', () => ({ + PromoteRcBody: () => ( +
Hello
+ ), +})); + +describe('PromoteRc', () => { + it('return early if no latest release present', () => { + const { getByTestId } = render(); + + expect( + getByTestId(TEST_IDS.components.noLatestRelease), + ).toBeInTheDocument(); + }); + + it('should display not-rc warning', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.promoteRc.notRcWarning)).toBeInTheDocument(); + }); + + it('should display PromoteRcBody', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.promoteRc.mockedPromoteRcBody), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.tsx new file mode 100644 index 0000000000..ecb584a6f4 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { Box, Typography } from '@material-ui/core'; + +import { ComponentConfigPromoteRc } from '../../types/types'; +import { GetLatestReleaseResult } from '../../api/GitReleaseClient'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { NoLatestRelease } from '../../components/NoLatestRelease'; +import { PromoteRcBody } from './PromoteRcBody'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +interface PromoteRcProps { + latestRelease: GetLatestReleaseResult['latestRelease']; + onSuccess?: ComponentConfigPromoteRc['onSuccess']; +} + +export const PromoteRc = ({ latestRelease, onSuccess }: PromoteRcProps) => { + function Body() { + if (latestRelease === null) { + return ; + } + + if (!latestRelease.prerelease) { + return ( + + + + Latest Git release is not a Release Candidate + + One can only promote Release Candidates to Release Versions + + + ); + } + + return ; + } + + return ( + + + Promote Release Candidate + + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.test.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.test.tsx new file mode 100644 index 0000000000..14b294e581 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { mockReleaseCandidateCalver } from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { PromoteRcBody } from './PromoteRcBody'; + +jest.mock('./hooks/usePromoteRc', () => ({ + usePromoteRc: () => ({ + run: jest.fn(), + responseSteps: [], + progress: 0, + }), +})); + +describe('PromoteRcBody', () => { + it('should display CTA', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.promoteRc.cta)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.tsx new file mode 100644 index 0000000000..29e1de52cd --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Button, Typography, Box } from '@material-ui/core'; + +import { ComponentConfigPromoteRc } from '../../types/types'; +import { Differ } from '../../components/Differ'; +import { GetLatestReleaseResult } from '../../api/GitReleaseClient'; +import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { usePromoteRc } from './hooks/usePromoteRc'; + +interface PromoteRcBodyProps { + rcRelease: NonNullable; + onSuccess?: ComponentConfigPromoteRc['onSuccess']; +} + +export const PromoteRcBody = ({ rcRelease, onSuccess }: PromoteRcBodyProps) => { + const releaseVersion = rcRelease.tagName.replace('rc-', 'version-'); + + const { progress, responseSteps, run, runInvoked } = usePromoteRc({ + rcRelease, + releaseVersion, + onSuccess, + }); + + if (responseSteps.length > 0) { + return ( + + ); + } + + return ( + <> + + + Promotes the current Release Candidate to a Release Version. + + + + + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.test.ts b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.test.ts new file mode 100644 index 0000000000..1c4e794227 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockReleaseCandidateCalver, + mockUser, +} from '../../../test-helpers/test-helpers'; +import { usePromoteRc } from './usePromoteRc'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); + +describe('usePromoteRc', () => { + beforeEach(jest.clearAllMocks); + + it('should return the expected responseSteps and progress', async () => { + const { result } = renderHook(() => + usePromoteRc({ + rcRelease: mockReleaseCandidateCalver, + releaseVersion: 'version-1.2.3', + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(4); + }); + + it('should return the expected responseSteps and progress (with onSuccess)', async () => { + const { result } = renderHook(() => + usePromoteRc({ + rcRelease: mockReleaseCandidateCalver, + releaseVersion: 'version-1.2.3', + onSuccess: jest.fn(), + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.current.responseSteps).toHaveLength(5); + expect(result.current).toMatchInlineSnapshot(` + Object { + "progress": 100, + "responseSteps": Array [ + Object { + "message": "Fetched most recent commit from release branch", + "secondaryMessage": "with sha \\"latestCommit.sha\\"", + }, + Object { + "message": "Created Tag Object", + "secondaryMessage": "with sha \\"mock_tag_object_sha\\"", + }, + Object { + "message": "Create Tag Reference", + "secondaryMessage": "with ref \\"mock_createRef_ref\\"", + }, + Object { + "link": "https://mock_update_release_html_url", + "message": "Promoted \\"mock_update_release_name\\"", + "secondaryMessage": "from \\"rc-2020.01.01_1\\" to \\"mock_update_release_tag_name\\"", + }, + Object { + "icon": "success", + "message": "Success callback successfully called 🚀", + }, + ], + "run": [Function], + "runInvoked": true, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.ts b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.ts new file mode 100644 index 0000000000..8b1ffa089b --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useEffect } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core'; + +import { CardHook, ComponentConfigPromoteRc } from '../../../types/types'; +import { GetLatestReleaseResult } from '../../../api/GitReleaseClient'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useProjectContext } from '../../../contexts/ProjectContext'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { useUserContext } from '../../../contexts/UserContext'; + +interface PromoteRc { + rcRelease: NonNullable; + releaseVersion: string; + onSuccess?: ComponentConfigPromoteRc['onSuccess']; +} + +export function usePromoteRc({ + rcRelease, + releaseVersion, + onSuccess, +}: PromoteRc): CardHook { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); + const { project } = useProjectContext(); + const { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + } = useResponseSteps(); + + /** + * (1) Fetch most recent release branch commit + */ + const [latestReleaseBranchCommitSha, run] = useAsyncFn(async () => { + const { commit: latestCommit } = await pluginApiClient + .getCommit({ + owner: project.owner, + repo: project.repo, + ref: rcRelease.targetCommitish, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Fetched most recent commit from release branch', + secondaryMessage: `with sha "${latestCommit.sha}"`, + }); + + return { + ...latestCommit, + }; + }); + + /** + * (2) Create tag object for our soon-to-be-created annotated tag + */ + const tagObjectRes = useAsync(async () => { + abortIfError(latestReleaseBranchCommitSha.error); + if (!latestReleaseBranchCommitSha.value) return undefined; + + const { tagObject } = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: releaseVersion, + object: latestReleaseBranchCommitSha.value.sha, + taggerName: user.username, + taggerEmail: user.email, + message: TAG_OBJECT_MESSAGE, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Tag Object', + secondaryMessage: `with sha "${tagObject.tagSha}"`, + }); + + return { + ...tagObject, + }; + }, [latestReleaseBranchCommitSha.value, latestReleaseBranchCommitSha.error]); + + /** + * (3) Create reference for tag object + */ + const createRcRes = useAsync(async () => { + abortIfError(tagObjectRes.error); + if (!tagObjectRes.value) return undefined; + + const { reference: createdRef } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + ref: `refs/tags/${releaseVersion}`, + sha: tagObjectRes.value.tagSha, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Tag reference "${releaseVersion}" already exists`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Create Tag Reference', + secondaryMessage: `with ref "${createdRef.ref}"`, + }); + + return { + ...createdRef, + }; + }, [tagObjectRes.value, tagObjectRes.error]); + + /** + * (4) Promote Release Candidate to Release Version + */ + const promotedReleaseRes = useAsync(async () => { + abortIfError(createRcRes.error); + if (!createRcRes.value) return undefined; + + const { release } = await pluginApiClient + .updateRelease({ + owner: project.owner, + repo: project.repo, + releaseId: rcRelease.id, + tagName: releaseVersion, + prerelease: false, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Promoted "${release.name}"`, + secondaryMessage: `from "${rcRelease.tagName}" to "${release.tagName}"`, + link: release.htmlUrl, + }); + + return { + ...release, + }; + }, [createRcRes.value, createRcRes.error]); + + /** + * (5) Run onSuccess if defined + */ + useAsync(async () => { + if (onSuccess && !!promotedReleaseRes.value) { + abortIfError(promotedReleaseRes.error); + + try { + await onSuccess?.({ + gitReleaseUrl: promotedReleaseRes.value.htmlUrl, + gitReleaseName: promotedReleaseRes.value.name, + previousTagUrl: rcRelease.htmlUrl, + previousTag: rcRelease.tagName, + updatedTagUrl: promotedReleaseRes.value.htmlUrl, + updatedTag: promotedReleaseRes.value.tagName, + }); + } catch (error) { + asyncCatcher(error); + } + + addStepToResponseSteps({ + message: 'Success callback successfully called 🚀', + icon: 'success', + }); + } + }, [promotedReleaseRes.value, promotedReleaseRes.error]); + + const TOTAL_STEPS = 4 + (!!onSuccess ? 1 : 0); + const [progress, setProgress] = useState(0); + useEffect(() => { + setProgress((responseSteps.length / TOTAL_STEPS) * 100); + }, [TOTAL_STEPS, responseSteps.length]); + + return { + progress, + responseSteps, + run, + runInvoked: Boolean( + promotedReleaseRes.loading || + promotedReleaseRes.value || + promotedReleaseRes.error, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.test.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.test.tsx new file mode 100644 index 0000000000..bbe2e23e06 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockSearchCalver, + mockUser, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { Owner } from './Owner'; + +jest.mock('react-router', () => ({ + useNavigate: jest.fn(), + useLocation: jest.fn(() => ({ + search: mockSearchCalver, + })), +})); +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: jest.fn(() => ({ + project: mockCalverProject, + })), +})); +jest.mock('../../contexts/UserContext', () => ({ + useUserContext: jest.fn(() => ({ + user: mockUser, + })), +})); + +describe('Owner', () => { + beforeEach(jest.clearAllMocks); + + it('should render select', async () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.form.owner.select)); + expect(getByTestId(TEST_IDS.form.owner.select)).toBeInTheDocument(); + }); + + it('should render select for empty owners', async () => { + (useProjectContext as jest.Mock).mockReturnValue({ + project: { ...mockCalverProject, owner: '' }, + }); + + const { getAllByTestId, getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getAllByTestId(TEST_IDS.form.owner.empty)); + expect(getAllByTestId(TEST_IDS.form.owner.empty)).toMatchInlineSnapshot(` + Array [ +

+ Select an owner (org or user) +

, +

+ Custom queries can be made via the query param + + + owner + +

, + ] + `); + }); + + it('should handle errors', async () => { + (mockApiClient.getOwners as jest.Mock).mockImplementationOnce(async () => { + throw new Error('Kaboom'); + }); + + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument(); + await waitFor(() => screen.getByTestId(TEST_IDS.form.owner.error)); + expect(getByTestId(TEST_IDS.form.owner.error)).toMatchInlineSnapshot(` +

+ Encountered an error ( + Kaboom + ) +

+ `); + }); +}); diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.tsx new file mode 100644 index 0000000000..5d6ea4f75d --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useNavigate } from 'react-router'; +import { useAsync } from 'react-use'; +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + Box, +} from '@material-ui/core'; +import { Progress, useApi } from '@backstage/core'; + +import { gitReleaseManagerApiRef } from '../../api/serviceApiRef'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useFormClasses } from './styles'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useQueryHandler } from '../../hooks/useQueryHandler'; +import { useUserContext } from '../../contexts/UserContext'; + +export function Owner() { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const { user } = useUserContext(); + const formClasses = useFormClasses(); + const navigate = useNavigate(); + const { getQueryParamsWithUpdates } = useQueryHandler(); + + const { loading, error, value } = useAsync(() => pluginApiClient.getOwners()); + const owners = value?.owners ?? []; + const customOwnerFromUrl = !owners + .concat(['', user.username]) + .includes(project.owner); + + return ( + + {loading ? ( + + + + ) : ( + <> + Owners + + + {error && ( + + Encountered an error ({error.message}) + + )} + + {!error && project.owner.length === 0 && ( + <> + + Select an owner (org or user) + + + Custom queries can be made via the query param{' '} + owner + + + )} + + )} + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.test.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.test.tsx new file mode 100644 index 0000000000..fe942d6c54 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockSearchCalver, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { Repo } from './Repo'; + +jest.mock('react-router', () => ({ + useNavigate: jest.fn(), + useLocation: jest.fn(() => ({ + search: mockSearchCalver, + })), +})); +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: jest.fn(() => ({ + project: mockCalverProject, + })), +})); + +describe('Repo', () => { + beforeEach(jest.clearAllMocks); + + it('should render select', async () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.form.repo.select)); + expect(getByTestId(TEST_IDS.form.repo.select)).toBeInTheDocument(); + }); + + it('should render select for empty repo', async () => { + (useProjectContext as jest.Mock).mockReturnValue({ + project: { ...mockCalverProject, repo: '' }, + }); + + const { getAllByTestId, getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getAllByTestId(TEST_IDS.form.repo.empty)); + expect(getAllByTestId(TEST_IDS.form.repo.empty)).toMatchInlineSnapshot(` + Array [ +

+ Select a repository +

, +

+ Custom queries can be made via the query param + + + repo + +

, + ] + `); + }); + + it('should handle errors', async () => { + (mockApiClient.getRepositories as jest.Mock).mockImplementationOnce( + async () => { + throw new Error('Kaboom'); + }, + ); + + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument(); + await waitFor(() => screen.getByTestId(TEST_IDS.form.repo.error)); + expect(getByTestId(TEST_IDS.form.repo.error)).toMatchInlineSnapshot(` +

+ Encountered an error ( + Kaboom + ") +

+ `); + }); +}); diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.tsx new file mode 100644 index 0000000000..bc713709b1 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useAsync } from 'react-use'; +import { useNavigate } from 'react-router'; +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + Box, +} from '@material-ui/core'; +import { Progress, useApi } from '@backstage/core'; + +import { gitReleaseManagerApiRef } from '../../api/serviceApiRef'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useFormClasses } from './styles'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useQueryHandler } from '../../hooks/useQueryHandler'; + +export function Repo() { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const navigate = useNavigate(); + const formClasses = useFormClasses(); + const { getQueryParamsWithUpdates } = useQueryHandler(); + + const { loading, error, value } = useAsync( + async () => pluginApiClient.getRepositories({ owner: project.owner }), + [project.owner], + ); + + if (project.owner.length === 0) { + return null; + } + + const repositories = value?.repositories ?? []; + const customRepoFromUrl = !repositories.concat(['']).includes(project.repo); + + return ( + + {loading ? ( + + + + ) : ( + <> + Repositories + + + {error && ( + + Encountered an error ({error.message}") + + )} + + {!error && project.repo.length === 0 && ( + <> + + Select a repository + + + Custom queries can be made via the query param{' '} + repo + + + )} + + )} + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/RepoDetailsForm.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/RepoDetailsForm.tsx new file mode 100644 index 0000000000..e77f73e0f6 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/RepoDetailsForm.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Owner } from './Owner'; +import { Repo } from './Repo'; +import { VersioningStrategy } from './VersioningStrategy'; + +export function RepoDetailsForm() { + return ( + <> + + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.test.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.test.tsx new file mode 100644 index 0000000000..42a17f9b43 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { + mockSemverProject, + mockSearchCalver, +} from '../../test-helpers/test-helpers'; +import { VersioningStrategy } from './VersioningStrategy'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router', () => ({ + useNavigate: () => mockNavigate, + useLocation: jest.fn(() => ({ + search: mockSearchCalver, + })), +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockSemverProject, + }), +})); + +describe('Repo', () => { + beforeEach(jest.clearAllMocks); + + it('should render radio group with default values and handle changes', async () => { + const { getByLabelText } = render(); + + const radio1 = getByLabelText('Semantic versioning'); + const radio2 = getByLabelText('Calendar versioning'); + + expect(radio1).toBeChecked(); + expect(radio2).not.toBeChecked(); + + fireEvent.click(radio2); + expect(mockNavigate.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "?versioningStrategy=calver&owner=mock_owner&repo=mock_repo", + Object { + "replace": true, + }, + ], + ] + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.tsx new file mode 100644 index 0000000000..67ca47f566 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, +} from '@material-ui/core'; + +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useQueryHandler } from '../../hooks/useQueryHandler'; +import { VERSIONING_STRATEGIES } from '../../constants/constants'; + +export function VersioningStrategy() { + const navigate = useNavigate(); + const { project } = useProjectContext(); + const { getParsedQuery, getQueryParamsWithUpdates } = useQueryHandler(); + + useEffect(() => { + const { parsedQuery } = getParsedQuery(); + + if (!parsedQuery.versioningStrategy && !project.isProvidedViaProps) { + const { queryParams } = getQueryParamsWithUpdates({ + updates: [ + { key: 'versioningStrategy', value: project.versioningStrategy }, + ], + }); + + navigate(`?${queryParams}`, { replace: true }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + Versioning strategy + + { + const { queryParams } = getQueryParamsWithUpdates({ + updates: [{ key: 'versioningStrategy', value: event.target.value }], + }); + + navigate(`?${queryParams}`, { replace: true }); + }} + > + } + label="Semantic versioning" + /> + + } + label="Calendar versioning" + /> + + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/styles.ts b/plugins/git-release-manager/src/features/RepoDetailsForm/styles.ts new file mode 100644 index 0000000000..274d0523ac --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/styles.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createStyles, makeStyles, Theme } from '@material-ui/core'; + +export const useFormClasses = makeStyles((theme: Theme) => + createStyles({ + formControl: { + margin: theme.spacing(1), + minWidth: 120, + }, + selectEmpty: { + marginTop: theme.spacing(2), + }, + }), +); diff --git a/plugins/git-release-manager/src/features/Stats/DialogBody.tsx b/plugins/git-release-manager/src/features/Stats/DialogBody.tsx new file mode 100644 index 0000000000..219bc2a146 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/DialogBody.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert } from '@material-ui/lab'; +import { + makeStyles, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@material-ui/core'; +import { Progress } from '@backstage/core'; + +import { getMappedReleases } from './helpers/getMappedReleases'; +import { getReleaseStats } from './helpers/getReleaseStats'; +import { Info } from './Info/Info'; +import { ReleaseStatsContext } from './contexts/ReleaseStatsContext'; +import { Row } from './Row/Row'; +import { useGetStats } from './hooks/useGetStats'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { Warn } from './Warn'; + +const useStyles = makeStyles({ + table: { + minWidth: 650, + }, +}); + +export function DialogBody() { + const classes = useStyles(); + const { stats } = useGetStats(); + const { project } = useProjectContext(); + + if (stats.error) { + return ( + Unexpected error: {stats.error.message} + ); + } + + if (stats.loading) { + return ; + } + + if (!stats.value) { + return Couldn't find any stats :(; + } + + const { allReleases, allTags } = stats.value; + const { mappedReleases } = getMappedReleases({ allReleases, project }); + const { releaseStats } = getReleaseStats({ + mappedReleases, + allTags, + project, + }); + + return ( + + + + + + + + + Release + Created at + # candidate patches + # release patches + + + + + {Object.entries(releaseStats.releases).map( + ([baseVersion, releaseStat], index) => { + return ( + + ); + }, + )} + +
+ + {(releaseStats.unmappableTags.length > 0 || + releaseStats.unmatchedTags.length > 0 || + releaseStats.unmatchedReleases.length > 0) && } +
+
+ ); +} diff --git a/plugins/git-release-manager/src/features/Stats/DialogTitle.tsx b/plugins/git-release-manager/src/features/Stats/DialogTitle.tsx new file mode 100644 index 0000000000..92e8d0d588 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/DialogTitle.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + createStyles, + IconButton, + Theme, + Typography, + withStyles, + WithStyles, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; + +import { Stats } from './Stats'; + +interface DialogTitleProps extends WithStyles { + children: React.ReactNode; + setShowStats: React.ComponentProps['setShowStats']; +} + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }); + +export const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { + const { children, classes, setShowStats, ...other } = props; + + return ( + + {children} + setShowStats(false)} + > + + + + ); +}); diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx new file mode 100644 index 0000000000..659956ddb1 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; + +export function AverageReleaseTime({ + averageReleaseTime, +}: { + averageReleaseTime: ReturnType< + typeof useGetReleaseTimes + >['averageReleaseTime']; +}) { + if (averageReleaseTime.length === 0) { + return <>-; + } + + const average = averageReleaseTime.reduce( + (acc, { daysWithHours }) => { + acc.daysWithHours += daysWithHours / averageReleaseTime.length; + return acc; + }, + { daysWithHours: 0 }, + ); + + const days = Math.floor(average.daysWithHours); + const hours = getDecimalNumber((average.daysWithHours - days) * 24, 1); + + return ( + <> + {days} days {hours} hours + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx new file mode 100644 index 0000000000..9fbd2bc925 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Box, + Button, + Tooltip as MaterialTooltip, + Typography, +} from '@material-ui/core'; +import { BarChart, Bar, XAxis, YAxis, Legend, Tooltip } from 'recharts'; + +import { AverageReleaseTime } from './AverageReleaseTime'; +import { LinearProgressWithLabel } from '../../../../components/ResponseStepDialog/LinearProgressWithLabel'; +import { LongestReleaseTime } from './LongestReleaseTime'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; +import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext'; + +export function InDepth() { + const { releaseStats } = useReleaseStatsContext(); + const { + averageReleaseTime, + progress, + releaseCommitPairs, + run, + } = useGetReleaseTimes(); + + const skipped = + Object.keys(releaseStats.releases).length - releaseCommitPairs.length; + + return ( + + + In-depth + + + + + + Release time + + + Release time is derived by comparing{' '} + createdAt of the commits belonging to the first and last + tag of each release. Releases without patches will have tags + pointing towards the same commit and will thus be omitted. This + project will omit {skipped} out of the total{' '} + {Object.keys(releaseStats.releases).length} releases. + + + + + + + In numbers + + + Average release time:{' '} + + + + + Longest release:{' '} + + + + + + {progress === 0 && ( + + + + )} + + + + + + 0 + ? averageReleaseTime + : [{ version: 'x.y.z', days: 0 }] + } + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + layout="vertical" + > + + + + + + + + {progress > 0 && progress < 100 && ( + + + + )} + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx new file mode 100644 index 0000000000..7fd5dbd572 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; + +export function LongestReleaseTime({ + averageReleaseTime, +}: { + averageReleaseTime: ReturnType< + typeof useGetReleaseTimes + >['averageReleaseTime']; +}) { + if (averageReleaseTime.length === 0) { + return <>-; + } + + const longestRelease = [...averageReleaseTime].sort( + (a, b) => b.daysWithHours - a.daysWithHours, + )[0]; + + return ( + <> + {longestRelease.version} ({longestRelease.days} days{' '} + {getDecimalNumber(longestRelease.hours, 1)} hours ) + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/Info.tsx b/plugins/git-release-manager/src/features/Stats/Info/Info.tsx new file mode 100644 index 0000000000..1c21f66e2c --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/Info.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Paper } from '@material-ui/core'; + +import { InDepth } from './InDepth/InDepth'; +import { Summary } from './Summary'; + +export function Info() { + return ( + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/Summary.tsx b/plugins/git-release-manager/src/features/Stats/Info/Summary.tsx new file mode 100644 index 0000000000..ff872dfffc --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/Summary.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Box, + makeStyles, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@material-ui/core'; + +import { getDecimalNumber } from '../helpers/getDecimalNumber'; +import { getSummary } from '../helpers/getSummary'; +import { useReleaseStatsContext } from '../contexts/ReleaseStatsContext'; + +const useStyles = makeStyles({ + table: { + minWidth: 650, + }, +}); + +export function Summary() { + const { releaseStats } = useReleaseStatsContext(); + const { summary } = getSummary({ releaseStats }); + const classes = useStyles(); + + return ( + + Summary + + + Total releases: {summary.totalReleases} + + + + + + + + Patches + Patches per release + + + + + + + Release Candidate + + {summary.totalCandidatePatches} + + {getDecimalNumber( + summary.totalCandidatePatches / summary.totalReleases, + )} + + + + + + Release Version + + {summary.totalVersionPatches} + + {getDecimalNumber( + summary.totalVersionPatches / summary.totalReleases, + )} + + + + + + Total + + + {summary.totalCandidatePatches + summary.totalVersionPatches} + + + {getDecimalNumber( + (summary.totalCandidatePatches + + summary.totalVersionPatches) / + summary.totalReleases, + )} + + + +
+
+
+ ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx new file mode 100644 index 0000000000..2e7f24c194 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getReleaseCommitPairs } from './getReleaseCommitPairs'; + +describe('getReleaseCommitPairs', () => { + it('should work', () => { + const nonPublishedRelease = { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.0', + tagSha: 'sha-1.0.0', + tagType: 'tag' as const, + }, + { + tagName: 'rc-1.0.1', + tagSha: 'sha-1.0.1', + tagType: 'tag' as const, + }, + ], + versions: [], + }; + + const releaseWithoutPatches = { + baseVersion: '2.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-2.0.0', + tagSha: 'sha-2.0.0', + tagType: 'tag' as const, + }, + ], + versions: [ + { + tagName: 'version-2.0.0', + tagSha: 'sha-2.0.0', + tagType: 'tag' as const, + }, + ], + }; + + const releaseWithPatches = { + baseVersion: '3.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-3.0.1', + tagSha: 'sha-3.0.1', + tagType: 'tag' as const, + }, + { + tagName: 'rc-3.0.0', + tagSha: 'sha-3.0.0', + tagType: 'tag' as const, + }, + ], + versions: [ + { + tagName: 'version-3.0.1', + tagSha: 'sha-3.0.1', + tagType: 'tag' as const, + }, + ], + }; + + const result = getReleaseCommitPairs({ + releaseStats: { + releases: { + nonPublishedRelease, // Should be omitted + releaseWithoutPatches, // Should be omitted + releaseWithPatches, + }, + unmatchedReleases: [], + unmappableTags: [], + unmatchedTags: [], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "releaseCommitPairs": Array [ + Object { + "baseVersion": "2.0", + "endCommit": Object { + "tagName": "version-2.0.0", + "tagSha": "sha-2.0.0", + "tagType": "tag", + }, + "startCommit": Object { + "tagName": "rc-2.0.0", + "tagSha": "sha-2.0.0", + "tagType": "tag", + }, + }, + Object { + "baseVersion": "3.0", + "endCommit": Object { + "tagName": "version-3.0.1", + "tagSha": "sha-3.0.1", + "tagType": "tag", + }, + "startCommit": Object { + "tagName": "rc-3.0.0", + "tagSha": "sha-3.0.0", + "tagType": "tag", + }, + }, + ], + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx new file mode 100644 index 0000000000..ae54a678d3 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReleaseCommitPairs } from '../hooks/useGetReleaseTimes'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; + +export function getReleaseCommitPairs({ + releaseStats, +}: { + releaseStats: ReleaseStats; +}) { + const releaseCommitPairs = Object.values(releaseStats.releases).reduce( + (acc: ReleaseCommitPairs, release) => { + const startTag = [...release.candidates].reverse()[0]; + const endTag = release.versions[0]; + + // Missing Release Candidate for unknown reason + if (!startTag) { + return acc; + } + + // Missing Release Version (likely prerelease) + if (!endTag) { + return acc; + } + + return acc.concat({ + baseVersion: release.baseVersion, + startCommit: { ...startTag }, + endCommit: { ...endTag }, + }); + }, + [], + ); + + return { + releaseCommitPairs, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx b/plugins/git-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx new file mode 100644 index 0000000000..3d1d5c5bb4 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { DateTime } from 'luxon'; +import { useApi } from '@backstage/core'; + +import { getReleaseCommitPairs } from '../helpers/getReleaseCommitPairs'; +import { gitReleaseManagerApiRef } from '../../../../api/serviceApiRef'; +import { useProjectContext } from '../../../../contexts/ProjectContext'; +import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext'; +import { getTagDates } from '../../helpers/getTagDates'; + +export type ReleaseCommitPairs = Array<{ + baseVersion: string; + startCommit: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }; + endCommit: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }; +}>; + +type ReleaseTime = { + version: string; + daysWithHours: number; + days: number; + hours: number; + startCommitCreatedAt?: string; + endCommitCreatedAt?: string; +}; + +export function useGetReleaseTimes() { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const { releaseStats } = useReleaseStatsContext(); + const [averageReleaseTime, setAverageReleaseTime] = useState( + [], + ); + const [progress, setProgress] = useState(0); + const { releaseCommitPairs } = getReleaseCommitPairs({ releaseStats }); + + const [releaseTimeResult, run] = useAsyncFn(() => { + setProgress(0); + return getAndSetReleaseTime({ pairIndex: 0 }); + }); + + useAsync(async () => { + if (averageReleaseTime.length === 0) return; + if (releaseCommitPairs.length === averageReleaseTime.length) return; + + await getAndSetReleaseTime({ pairIndex: averageReleaseTime.length }); + }, [releaseTimeResult.value, averageReleaseTime]); + + useEffect(() => { + const unboundedProgress = Math.round( + (averageReleaseTime.length / releaseCommitPairs.length) * 100, + ); + const boundedProgress = unboundedProgress > 100 ? 100 : unboundedProgress; + + setProgress(boundedProgress); + }, [averageReleaseTime.length, releaseCommitPairs.length]); + + async function getAndSetReleaseTime({ pairIndex }: { pairIndex: number }) { + const { baseVersion, startCommit, endCommit } = releaseCommitPairs[ + pairIndex + ]; + + const { + startDate: startCommitCreatedAt, + endDate: endCommitCreatedAt, + } = await getTagDates({ + pluginApiClient, + project, + startTag: startCommit, + endTag: endCommit, + }); + + const releaseTime: ReleaseTime = { + version: baseVersion, + daysWithHours: 0, + days: 0, + hours: 0, + startCommitCreatedAt, + endCommitCreatedAt, + }; + + if (startCommitCreatedAt && endCommitCreatedAt) { + const { days: luxDays = 0, hours: luxHours = 0 } = DateTime.fromISO( + endCommitCreatedAt, + ) + .diff(DateTime.fromISO(startCommitCreatedAt), ['days', 'hours']) + .toObject(); + + releaseTime.daysWithHours = luxDays + luxHours / 24; + releaseTime.days = luxDays; + releaseTime.hours = luxHours; + } + + setAverageReleaseTime([...averageReleaseTime, releaseTime]); + } + + return { + releaseCommitPairs, + averageReleaseTime, + progress, + run, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/Row.tsx b/plugins/git-release-manager/src/features/Stats/Row/Row.tsx new file mode 100644 index 0000000000..1d6e298df0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/Row.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { DateTime } from 'luxon'; +import { + Collapse, + IconButton, + makeStyles, + TableCell, + TableRow, +} from '@material-ui/core'; +import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { Link } from '@backstage/core'; + +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { RowCollapsed } from './RowCollapsed/RowCollapsed'; + +const useRowStyles = makeStyles({ + root: { + '& > *': { + borderBottom: 'unset', + }, + }, +}); + +interface RowProps { + baseVersion: string; + releaseStat: ReleaseStats['releases']['0']; +} + +export function Row({ baseVersion, releaseStat }: RowProps) { + const [open, setOpen] = useState(false); + const classes = useRowStyles(); + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + + + {baseVersion} + {releaseStat.versions.length === 0 ? ' (prerelease)' : ''} + + + + + {releaseStat.createdAt + ? DateTime.fromISO(releaseStat.createdAt).toFormat('yyyy-MM-dd') + : '-'} + + + {releaseStat.candidates.length} + + {Math.max(0, releaseStat.versions.length - 1)} + + + + + + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx new file mode 100644 index 0000000000..e07b74a171 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; + +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; + +export function ReleaseTagList({ + releaseStat, +}: { + releaseStat: ReleaseStats['releases']['0']; +}) { + return ( + + {releaseStat.versions.length > 0 && ( + + {releaseStat.versions.map(version => ( + + {version.tagName} + + ))} + + )} + + {releaseStat.versions.length > 0 && ( + + {' 🚀 '} + + )} + + + {releaseStat.candidates.map(candidate => ( + + {candidate.tagName} + + ))} + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx new file mode 100644 index 0000000000..0a499f5cf6 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx @@ -0,0 +1,141 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useAsync } from 'react-use'; +import { DateTime } from 'luxon'; +import { Box, Typography } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { Progress, useApi } from '@backstage/core'; + +import { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { getTagDates } from '../../helpers/getTagDates'; +import { gitReleaseManagerApiRef } from '../../../../api/serviceApiRef'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; +import { useProjectContext } from '../../../../contexts/ProjectContext'; + +interface ReleaseTimeProps { + releaseStat: ReleaseStats['releases']['0']; +} + +export function ReleaseTime({ releaseStat }: ReleaseTimeProps) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + + const releaseTimes = useAsync(() => + getTagDates({ + pluginApiClient, + project, + startTag: [...releaseStat.candidates].reverse()[0], + endTag: releaseStat.versions[0], + }), + ); + + if (releaseTimes.loading || releaseTimes.loading) { + return ( + + + + ); + } + + if (releaseTimes.error) { + return ( + + Failed to fetch the first Release Candidate commit ( + {releaseTimes.error.message}) + + ); + } + + const { days = 0, hours = 0 } = + releaseTimes.value?.startDate && releaseTimes.value?.endDate + ? DateTime.fromISO(releaseTimes.value.endDate) + .diff(DateTime.fromISO(releaseTimes.value.startDate), [ + 'days', + 'hours', + ]) + .toObject() + : { days: -1 }; + + return ( + + + + {releaseStat.versions.length === 0 ? '-' : 'Release completed '} + {releaseTimes.value?.endDate && + DateTime.fromISO(releaseTimes.value.endDate).toFormat('yyyy-MM-dd')} + + + + + + {days === -1 ? ( + <>Ongoing + ) : ( + <> + Completed in: {days} days {getDecimalNumber(hours, 1)} hours + + )} + + + + + + Release Candidate created{' '} + {releaseTimes.value?.startDate && + DateTime.fromISO(releaseTimes.value.startDate).toFormat( + 'yyyy-MM-dd', + )} + + + + ); +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx new file mode 100644 index 0000000000..101954e49d --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Box } from '@material-ui/core'; + +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; +import { ReleaseTagList } from './ReleaseTagList'; +import { ReleaseTime } from './ReleaseTime'; + +interface RowCollapsedProps { + releaseStat: ReleaseStats['releases']['0']; +} + +export function RowCollapsed({ releaseStat }: RowCollapsedProps) { + return ( + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Stats.tsx b/plugins/git-release-manager/src/features/Stats/Stats.tsx new file mode 100644 index 0000000000..e590c0a320 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Stats.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Button, Dialog, Theme, withStyles } from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import MuiDialogActions from '@material-ui/core/DialogActions'; +import MuiDialogContent from '@material-ui/core/DialogContent'; + +import { DialogBody } from './DialogBody'; +import { DialogTitle } from './DialogTitle'; +import { Transition } from '../../components/Transition'; + +const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +const DialogActions = withStyles((theme: Theme) => ({ + root: { + margin: 0, + padding: theme.spacing(1), + }, +}))(MuiDialogActions); + +interface StatsProps { + setShowStats: React.Dispatch>; +} + +export function Stats({ setShowStats }: StatsProps) { + return ( + + Stats + + + + + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Warn.tsx b/plugins/git-release-manager/src/features/Stats/Warn.tsx new file mode 100644 index 0000000000..24997cc9b3 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Warn.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert } from '@material-ui/lab'; +import { Box, Button } from '@material-ui/core'; + +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useReleaseStatsContext } from './contexts/ReleaseStatsContext'; + +export const Warn = () => { + const { releaseStats } = useReleaseStatsContext(); + const { project } = useProjectContext(); + + return ( + + + {releaseStats.unmappableTags.length > 0 && ( +
+ Failed to map {releaseStats.unmappableTags.length}{' '} + tags to releases +
+ )} + + {releaseStats.unmatchedTags.length > 0 && ( +
+ Failed to match {releaseStats.unmatchedTags.length}{' '} + tags to {project.versioningStrategy} +
+ )} + + {releaseStats.unmatchedReleases.length > 0 && ( +
+ Failed to match{' '} + {releaseStats.unmatchedReleases.length} releases to{' '} + {project.versioningStrategy} +
+ )} + + + + +
+
+ ); +}; diff --git a/plugins/git-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx b/plugins/git-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx new file mode 100644 index 0000000000..85f526b72e --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; + +export interface ReleaseStats { + unmappableTags: string[]; + unmatchedTags: string[]; + unmatchedReleases: string[]; + releases: { + [baseVersion: string]: { + baseVersion: string; + createdAt: string | null; + htmlUrl: string; + candidates: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }[]; + versions: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }[]; + }; + }; +} + +export const ReleaseStatsContext = createContext< + { releaseStats: ReleaseStats } | undefined +>(undefined); + +export const useReleaseStatsContext = () => { + const { releaseStats } = useContext(ReleaseStatsContext) ?? {}; + + if (!releaseStats) { + throw new GitReleaseManagerError('releaseStats not found'); + } + + return { + releaseStats, + }; +}; diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx new file mode 100644 index 0000000000..b4b1a1df4c --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDecimalNumber } from './getDecimalNumber'; + +describe('getDecimalNumber', () => { + it('should handle NaN', () => { + const result = getDecimalNumber(NaN); + + expect(result).toEqual(0); + }); + + it('should only handle decimals', () => { + const result = getDecimalNumber(1); + + expect(result).toEqual(1); + }); + + it('should get decimal number with default decimals = 2', () => { + const result = getDecimalNumber(1 / 3); + + expect(result).toMatchInlineSnapshot(`0.33`); + }); + + it('should get decimal number for decimals = 1', () => { + const result = getDecimalNumber(1 / 3, 1); + + expect(result).toMatchInlineSnapshot(`0.3`); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx new file mode 100644 index 0000000000..ab4a83fa79 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function getDecimalNumber(n: number, decimals = 2) { + if (isNaN(n)) { + return 0; + } + + if (n.toString().includes('.')) { + return parseFloat(n.toFixed(decimals)); + } + + return n; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx new file mode 100644 index 0000000000..fd5b23beaf --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getMappedReleases } from './getMappedReleases'; +import { mockSemverProject } from '../../../test-helpers/test-helpers'; + +describe('getMappedReleases', () => { + it('should get mapped releases', () => { + const createRelease = (tagName: string) => ({ + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + id: 1, + name: 'name', + tagName, + }); + + const result = getMappedReleases({ + project: mockSemverProject, + allReleases: [createRelease('rc-1.0.0'), createRelease('rc-1.1.0')], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "mappedReleases": Object { + "releases": Object { + "1.0": Object { + "baseVersion": "1.0", + "candidates": Array [], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + "1.1": Object { + "baseVersion": "1.1", + "candidates": Array [], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + }, + "unmappableTags": Array [], + "unmatchedReleases": Array [], + "unmatchedTags": Array [], + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.tsx new file mode 100644 index 0000000000..0ea94d29ac --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; +import { GetAllReleasesResult } from '../../../api/GitReleaseClient'; +import { Project } from '../../../contexts/ProjectContext'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; + +export function getMappedReleases({ + allReleases, + project, +}: { + allReleases: GetAllReleasesResult['releases']; + project: Project; +}) { + return { + mappedReleases: allReleases.reduce( + (acc: ReleaseStats, release) => { + const match = + project.versioningStrategy === 'semver' + ? release.tagName.match(semverRegexp) + : release.tagName.match(calverRegexp); + + if (!match) { + acc.unmatchedReleases.push(release.tagName); + return acc; + } + + const baseVersion = + project.versioningStrategy === 'semver' + ? `${match[2]}.${match[3]}` + : match[2]; + + if (!acc.releases[baseVersion]) { + acc.releases[baseVersion] = { + baseVersion, + createdAt: release.createdAt, + htmlUrl: release.htmlUrl, + candidates: [], + versions: [], + }; + + return acc; + } + + return acc; + }, + { + releases: {}, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], + }, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx new file mode 100644 index 0000000000..77e4fe8dc0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getReleaseStats } from './getReleaseStats'; +import { mockSemverProject } from '../../../test-helpers/test-helpers'; + +describe('getReleaseStats', () => { + it('should get releases with tags', () => { + const result = getReleaseStats({ + project: mockSemverProject, + mappedReleases: { + releases: { + '1.0': { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [], + versions: [], + }, + '1.1': { + baseVersion: '1.1', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [], + + versions: [], + }, + }, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], + }, + allTags: [ + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.0' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.1' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.2' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'version-1.0.2' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.1.1' }, + + { tagType: 'tag' as const, tagSha: 'unmatchable', tagName: 'rc-1/2/3' }, + { + tagType: 'tag' as const, + tagSha: 'unmappable', + tagName: 'rc-123.123.123', + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "releaseStats": Object { + "releases": Object { + "1.0": Object { + "baseVersion": "1.0", + "candidates": Array [ + Object { + "tagName": "rc-1.0.0", + "tagSha": "sha", + "tagType": "tag", + }, + Object { + "tagName": "rc-1.0.1", + "tagSha": "sha", + "tagType": "tag", + }, + Object { + "tagName": "rc-1.0.2", + "tagSha": "sha", + "tagType": "tag", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [ + Object { + "tagName": "version-1.0.2", + "tagSha": "sha", + "tagType": "tag", + }, + ], + }, + "1.1": Object { + "baseVersion": "1.1", + "candidates": Array [ + Object { + "tagName": "rc-1.1.1", + "tagSha": "sha", + "tagType": "tag", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + }, + "unmappableTags": Array [ + "rc-123.123.123", + ], + "unmatchedReleases": Array [], + "unmatchedTags": Array [ + "rc-1/2/3", + ], + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.tsx new file mode 100644 index 0000000000..e4e57c1114 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; +import { GetAllTagsResult } from '../../../api/GitReleaseClient'; +import { Project } from '../../../contexts/ProjectContext'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; + +export function getReleaseStats({ + allTags, + project, + mappedReleases, +}: { + allTags: GetAllTagsResult['tags']; + project: Project; + mappedReleases: ReleaseStats; +}) { + const releaseStats = allTags.reduce( + (acc: ReleaseStats, tag) => { + const match = + project.versioningStrategy === 'semver' + ? tag.tagName.match(semverRegexp) + : tag.tagName.match(calverRegexp); + + if (!match) { + acc.unmatchedTags.push(tag.tagName); + return acc; + } + + const prefix = match[1] as 'rc' | 'version'; + const baseVersion = + project.versioningStrategy === 'semver' + ? `${match[2]}.${match[3]}` // major.minor + : match[2]; // yyyy.MM.dd + + const release = acc.releases[baseVersion]; + + if (!release) { + acc.unmappableTags.push(tag.tagName); + return acc; + } + + const dest = release[prefix === 'rc' ? 'candidates' : 'versions']; + dest.push(tag); + + return acc; + }, + { + ...mappedReleases, + }, + ); + + return { + releaseStats, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getSummary.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.test.tsx new file mode 100644 index 0000000000..be6052c2a0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getSummary } from './getSummary'; +import { mockReleaseStats } from '../../../test-helpers/stats'; + +describe('getSummary', () => { + it('should get summary', () => { + const result = getSummary({ releaseStats: mockReleaseStats }); + + expect(result).toMatchInlineSnapshot(` + Object { + "summary": Object { + "totalCandidatePatches": 3, + "totalReleases": 2, + "totalVersionPatches": 1, + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getSummary.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.tsx new file mode 100644 index 0000000000..e42c5c7379 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; + +export function getSummary({ releaseStats }: { releaseStats: ReleaseStats }) { + return { + summary: Object.entries(releaseStats.releases).reduce( + ( + acc: { + totalReleases: number; + totalCandidatePatches: number; + totalVersionPatches: number; + }, + [_baseVersion, mappedRelease], + ) => { + const candidatePatches = + Object.keys(mappedRelease.candidates).length - 1; + const versionPatches = Object.keys(mappedRelease.versions).length - 1; + + acc.totalReleases += 1; + acc.totalCandidatePatches += + candidatePatches >= 0 ? candidatePatches : 0; + acc.totalVersionPatches += versionPatches >= 0 ? versionPatches : 0; + + return acc; + }, + { + totalReleases: 0, + totalCandidatePatches: 0, + totalVersionPatches: 0, + }, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.test.ts b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.test.ts new file mode 100644 index 0000000000..3274ecc31f --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.test.ts @@ -0,0 +1,212 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createMockCommit, + createMockTag, + mockApiClient, + mockSemverProject, +} from '../../../test-helpers/test-helpers'; +import { getTagDates } from './getTagDates'; + +describe('getTagDates', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + it('should get tag dates for startTag when is tag and endTag is undefined', async () => { + (mockApiClient.getTag as jest.Mock).mockResolvedValueOnce( + createMockTag({ date: 'TAG-START' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'tag', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": undefined, + "startDate": "TAG-START", + } + `); + }); + + it('should get tag dates for startTag when startTag is commit and endTag is undefined', async () => { + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_START' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'commit', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": undefined, + "startDate": "COMMIT_START", + } + `); + }); + + it('should get tag dates when startTag & endTag both are of type tag', async () => { + (mockApiClient.getTag as jest.Mock) + .mockResolvedValueOnce(createMockTag({ date: 'TAG-START' })) + .mockResolvedValueOnce(createMockTag({ date: 'TAG-END' })); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'tag', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'tag', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "TAG-END", + "startDate": "TAG-START", + } + `); + }); + + it('should get commit createdAt when startTag & endTag both are of type commit', async () => { + (mockApiClient.getCommit as jest.Mock) + .mockResolvedValueOnce(createMockCommit({ createdAt: 'COMMIT_START' })) + .mockResolvedValueOnce(createMockCommit({ createdAt: 'COMMIT_END' })); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'commit', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'commit', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "COMMIT_END", + "startDate": "COMMIT_START", + } + `); + }); + + it('should get commit createdAt when startTag is of type tag but endTag is of type commit', async () => { + (mockApiClient.getTag as jest.Mock).mockResolvedValueOnce( + createMockTag({ objectSha: 'OBJECT_SHA_START' }), + ); + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_START' }), + ); + + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_END' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'tag', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'commit', + }, + }); + + const { owner, repo } = mockSemverProject; + expect(mockApiClient.getTag).toHaveBeenCalledWith({ + owner, + repo, + tagSha: 'sha-start', + }); + expect((mockApiClient.getCommit as jest.Mock).mock.calls).toEqual([ + [{ owner, ref: 'sha-end', repo }], + [{ owner, ref: 'OBJECT_SHA_START', repo }], + ]); + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "COMMIT_START", + "startDate": "COMMIT_END", + } + `); + }); + + it('should get commit createdAt when endTag is of type tag but startTag is of type commit', async () => { + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_START' }), + ); + + (mockApiClient.getTag as jest.Mock).mockResolvedValueOnce( + createMockTag({ objectSha: 'OBJECT_SHA_END' }), + ); + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_END' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'commit', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'tag', + }, + }); + + const { owner, repo } = mockSemverProject; + expect(mockApiClient.getTag).toHaveBeenCalledWith({ + owner, + repo, + tagSha: 'sha-end', + }); + expect((mockApiClient.getCommit as jest.Mock).mock.calls).toEqual([ + [{ owner, ref: 'sha-start', repo }], + [{ owner, ref: 'OBJECT_SHA_END', repo }], + ]); + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "COMMIT_END", + "startDate": "COMMIT_START", + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.ts b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.ts new file mode 100644 index 0000000000..d79d7489fd --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitReleaseApi } from '../../../api/GitReleaseClient'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { Project } from '../../../contexts/ProjectContext'; + +interface GetTagDates { + pluginApiClient: GitReleaseApi; + project: Project; + startTag: { + tagSha: string; + tagType: 'tag' | 'commit'; + }; + endTag?: { + tagSha: string; + tagType: 'tag' | 'commit'; + }; +} + +export const getTagDates = async ({ + pluginApiClient, + project, + startTag, + endTag, +}: GetTagDates) => { + if (!endTag) { + if (startTag.tagType === 'tag') { + const { tag: startTagResponse } = await pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: startTag.tagSha, + }); + + return { + startDate: startTagResponse.date, + endDate: undefined, + }; + } + + // If tagType is not a 'tag', it has to be a commit + const { commit: startCommit } = await pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startTag.tagSha, + }); + + return { + startDate: startCommit.createdAt, + endDate: undefined, + }; + } + + if (startTag.tagType === 'tag' && endTag.tagType === 'tag') { + const [ + { tag: startTagResponse }, + { tag: endTagResponse }, + ] = await Promise.all([ + pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: startTag.tagSha, + }), + pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: endTag.tagSha, + }), + ]); + + return { + startDate: startTagResponse.date, + endDate: endTagResponse.date, + }; + } + + if (startTag.tagType === 'commit' && endTag.tagType === 'commit') { + const [{ commit: startCommit }, { commit: endCommit }] = await Promise.all([ + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startTag.tagSha, + }), + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: endTag.tagSha, + }), + ]); + + return { + startDate: startCommit.createdAt, + endDate: endCommit.createdAt, + }; + } + + if (startTag.tagType === 'tag' && endTag.tagType === 'commit') { + const [{ date: startDate }, { commit: endCommit }] = await Promise.all([ + getCommitFromTag({ pluginApiClient, project, tag: startTag }), + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: endTag.tagSha, + }), + ]); + + return { + startDate, + endDate: endCommit.createdAt, + }; + } + + if (startTag.tagType === 'commit' && endTag.tagType === 'tag') { + const [{ commit: startCommit }, { date: endDate }] = await Promise.all([ + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startTag.tagSha, + }), + getCommitFromTag({ pluginApiClient, project, tag: endTag }), + ]); + + return { + startDate: startCommit.createdAt, + endDate, + }; + } + + throw new GitReleaseManagerError( + `Failed to get tag dates for tags with type "${startTag.tagType}" and "${endTag.tagType}"`, + ); +}; + +async function getCommitFromTag({ + pluginApiClient, + project, + tag, +}: { + pluginApiClient: GetTagDates['pluginApiClient']; + project: GetTagDates['project']; + tag: GetTagDates['startTag'] | NonNullable; +}) { + const { tag: tagResponse } = await pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: tag.tagSha, + }); + const { commit: startCommit } = await pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: tagResponse.objectSha, + }); + + return { + date: startCommit.createdAt, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/hooks/useGetStats.ts b/plugins/git-release-manager/src/features/Stats/hooks/useGetStats.ts new file mode 100644 index 0000000000..e87d29c29a --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/hooks/useGetStats.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core'; +import { useAsync } from 'react-use'; + +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { useProjectContext } from '../../../contexts/ProjectContext'; + +export const useGetStats = () => { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + + const stats = useAsync(async () => { + const [{ releases: allReleases }, { tags: allTags }] = await Promise.all([ + pluginApiClient.getAllReleases({ + owner: project.owner, + repo: project.repo, + }), + pluginApiClient.getAllTags({ + owner: project.owner, + repo: project.repo, + }), + ]); + + return { + allReleases, + allTags, + }; + }, [project]); + + return { + stats, + }; +}; diff --git a/plugins/git-release-manager/src/helpers/createResponseStepError.test.ts b/plugins/git-release-manager/src/helpers/createResponseStepError.test.ts new file mode 100644 index 0000000000..b6069f2fd6 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/createResponseStepError.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createResponseStepError } from './createResponseStepError'; + +describe('createResponseStepError', () => { + it('should work', () => { + const result = createResponseStepError(new Error('banana')); + + expect(result).toMatchInlineSnapshot(` + Object { + "icon": "failure", + "message": "Something went wrong ❌", + "secondaryMessage": "Error message: banana", + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/createResponseStepError.ts b/plugins/git-release-manager/src/helpers/createResponseStepError.ts new file mode 100644 index 0000000000..7de042a7e7 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/createResponseStepError.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResponseStep } from '../types/types'; + +export function createResponseStepError(error: Error): ResponseStep { + return { + message: 'Something went wrong ❌', + secondaryMessage: `Error message: ${error.message}`, + icon: 'failure', + }; +} diff --git a/plugins/git-release-manager/src/helpers/getBumpedTag.test.ts b/plugins/git-release-manager/src/helpers/getBumpedTag.test.ts new file mode 100644 index 0000000000..27023f653f --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getBumpedTag.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCalverProject, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { getBumpedTag } from './getBumpedTag'; + +describe('getBumpedTag', () => { + describe('calver', () => { + it('should increment patch by 1', () => { + const result = getBumpedTag({ + project: mockCalverProject, + tag: 'rc-2020.01.01_1', + bumpLevel: 'patch', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-2020.01.01_2", + "error": undefined, + "tagParts": Object { + "calver": "2020.01.01", + "patch": 2, + "prefix": "rc", + }, + } + `); + }); + + it('should increment patch by 1 regardless of semver-specific arg "bumpLevel"', () => { + const result = getBumpedTag({ + project: mockCalverProject, + tag: 'rc-2020.01.01_1', + bumpLevel: 'major', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-2020.01.01_2", + "error": undefined, + "tagParts": Object { + "calver": "2020.01.01", + "patch": 2, + "prefix": "rc", + }, + } + `); + }); + }); + + describe('semver', () => { + it('should increment patch by 1', () => { + const result = getBumpedTag({ + project: mockSemverProject, + tag: 'rc-1.2.3', + bumpLevel: 'patch', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-1.2.4", + "error": undefined, + "tagParts": Object { + "major": 1, + "minor": 2, + "patch": 4, + "prefix": "rc", + }, + } + `); + }); + + it('should increment minor by 1', () => { + const result = getBumpedTag({ + project: mockSemverProject, + tag: 'rc-1.2.3', + bumpLevel: 'minor', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-1.3.0", + "error": undefined, + "tagParts": Object { + "major": 1, + "minor": 3, + "patch": 0, + "prefix": "rc", + }, + } + `); + }); + + it('should increment major by 1', () => { + const result = getBumpedTag({ + project: mockSemverProject, + tag: 'rc-1.2.3', + bumpLevel: 'major', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-2.0.0", + "error": undefined, + "tagParts": Object { + "major": 2, + "minor": 0, + "patch": 0, + "prefix": "rc", + }, + } + `); + }); + }); + + describe('errors', () => { + it('should propagate errors for invalid tags', () => { + const result = getBumpedTag({ + project: mockCalverProject, + tag: '😬', + bumpLevel: 'patch', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"😬\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/getBumpedTag.ts b/plugins/git-release-manager/src/helpers/getBumpedTag.ts new file mode 100644 index 0000000000..f8b976b01f --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getBumpedTag.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CalverTagParts } from './tagParts/getCalverTagParts'; +import { getTagParts } from './tagParts/getTagParts'; +import { isCalverTagParts } from './isCalverTagParts'; +import { Project } from '../contexts/ProjectContext'; +import { SEMVER_PARTS } from '../constants/constants'; +import { SemverTagParts } from './tagParts/getSemverTagParts'; + +export function getBumpedTag({ + project, + tag, + bumpLevel, +}: { + project: Project; + tag: string; + bumpLevel: keyof typeof SEMVER_PARTS; +}) { + const tagParts = getTagParts({ project, tag }); + + if (tagParts.error !== undefined) { + return { + error: tagParts.error, + }; + } + + if (isCalverTagParts(project, tagParts.tagParts)) { + return getPatchedCalverTag(tagParts.tagParts); + } + + return getBumpedSemverTag(tagParts.tagParts, bumpLevel); +} + +function getPatchedCalverTag(tagParts: CalverTagParts) { + const bumpedTagParts: CalverTagParts = { + ...tagParts, + patch: tagParts.patch + 1, + }; + const bumpedTag = `${bumpedTagParts.prefix}-${bumpedTagParts.calver}_${bumpedTagParts.patch}`; + + return { + bumpedTag, + tagParts: bumpedTagParts, + error: undefined, + }; +} + +function getBumpedSemverTag( + tagParts: SemverTagParts, + semverBumpLevel: keyof typeof SEMVER_PARTS, +) { + const { bumpedTagParts } = getBumpedSemverTagParts(tagParts, semverBumpLevel); + + const bumpedTag = `${bumpedTagParts.prefix}-${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`; + + return { + bumpedTag, + tagParts: bumpedTagParts, + error: undefined, + }; +} + +export function getBumpedSemverTagParts( + tagParts: SemverTagParts, + semverBumpLevel: keyof typeof SEMVER_PARTS, +) { + const bumpedTagParts = { + ...tagParts, + }; + + if (semverBumpLevel === 'major') { + bumpedTagParts.major = bumpedTagParts.major + 1; + bumpedTagParts.minor = 0; + bumpedTagParts.patch = 0; + } + + if (semverBumpLevel === 'minor') { + bumpedTagParts.minor = bumpedTagParts.minor + 1; + bumpedTagParts.patch = 0; + } + + if (semverBumpLevel === 'patch') { + bumpedTagParts.patch = bumpedTagParts.patch + 1; + } + + return { + bumpedTagParts, + }; +} diff --git a/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.test.ts b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.test.ts new file mode 100644 index 0000000000..c27e37ce86 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime } from 'luxon'; + +import { + mockCalverProject, + mockReleaseVersionCalver, + mockReleaseVersionSemver, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { getReleaseCandidateGitInfo } from './getReleaseCandidateGitInfo'; + +describe('getReleaseCandidateGitInfo', () => { + describe('DateTime', () => { + it('should format dates as expected', () => { + const formattedDate = DateTime.now().toFormat('yyyy.MM.dd'); + + expect(formattedDate).toMatch(/^\d{4}.\d{2}.\d{2}$/); + }); + }); + + describe('calver', () => { + it('should return correct Git info', () => { + expect( + getReleaseCandidateGitInfo({ + project: mockCalverProject, + latestRelease: mockReleaseVersionCalver, + semverBumpLevel: 'minor', + injectedDate: '2021.01.28', + }), + ).toMatchInlineSnapshot(` + Object { + "rcBranch": "rc/2021.01.28", + "rcReleaseTag": "rc-2021.01.28_0", + "releaseName": "Version 2021.01.28", + } + `); + }); + }); + + describe('semver', () => { + it("should return correct Git info when there's previous releases", () => { + expect( + getReleaseCandidateGitInfo({ + project: mockSemverProject, + latestRelease: mockReleaseVersionSemver, + semverBumpLevel: 'minor', + }), + ).toMatchInlineSnapshot(` + Object { + "rcBranch": "rc/1.3.0", + "rcReleaseTag": "rc-1.3.0", + "releaseName": "Version 1.3.0", + } + `); + }); + + it("should return correct Git info when there's no previous release", () => { + expect( + getReleaseCandidateGitInfo({ + project: mockSemverProject, + latestRelease: null, + semverBumpLevel: 'minor', + }), + ).toMatchInlineSnapshot(` + Object { + "rcBranch": "rc/0.0.1", + "rcReleaseTag": "rc-0.0.1", + "releaseName": "Version 0.0.1", + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.ts b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.ts new file mode 100644 index 0000000000..729439cea6 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime } from 'luxon'; + +import { getBumpedSemverTagParts } from './getBumpedTag'; +import { GetLatestReleaseResult } from '../api/GitReleaseClient'; +import { getSemverTagParts } from './tagParts/getSemverTagParts'; +import { Project } from '../contexts/ProjectContext'; +import { SEMVER_PARTS } from '../constants/constants'; + +interface GetReleaseCandidateGitInfo { + project: Project; + latestRelease: GetLatestReleaseResult['latestRelease']; + semverBumpLevel: keyof typeof SEMVER_PARTS; + injectedDate?: string; +} + +export const getReleaseCandidateGitInfo = ({ + project, + latestRelease, + semverBumpLevel, + injectedDate = DateTime.now().toFormat('yyyy.MM.dd'), +}: GetReleaseCandidateGitInfo) => { + if (project.versioningStrategy === 'calver') { + return { + rcBranch: `rc/${injectedDate}`, + rcReleaseTag: `rc-${injectedDate}_0`, + releaseName: `Version ${injectedDate}`, + }; + } + + if (!latestRelease) { + return { + rcBranch: 'rc/0.0.1', + rcReleaseTag: 'rc-0.0.1', + releaseName: 'Version 0.0.1', + }; + } + + const semverTagParts = getSemverTagParts(latestRelease.tagName); + if (semverTagParts.error !== undefined) { + return { + error: semverTagParts.error, + }; + } + + const { bumpedTagParts } = getBumpedSemverTagParts( + semverTagParts.tagParts, + semverBumpLevel, + ); + + const bumpedTag = `${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`; + + return { + rcBranch: `rc/${bumpedTag}`, + rcReleaseTag: `rc-${bumpedTag}`, + releaseName: `Version ${bumpedTag}`, + }; +}; diff --git a/plugins/git-release-manager/src/helpers/getShortCommitHash.test.ts b/plugins/git-release-manager/src/helpers/getShortCommitHash.test.ts new file mode 100644 index 0000000000..82cfd031cf --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getShortCommitHash.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getShortCommitHash } from './getShortCommitHash'; + +describe('getShortCommitHash', () => { + it('should get the short version of the commit hash', () => { + const result = getShortCommitHash( + 'bd3fc6f6351018a748bb7f94c0ecf6c1577d8e06', + ); + + expect(result).toEqual('bd3fc6f'); + expect(result.length).toEqual(7); + }); + + it('should throw for invalid commit hashes (too short)', () => { + expect(() => getShortCommitHash('bd3')).toThrowErrorMatchingInlineSnapshot( + `"Invalid shortCommitHash: less than 7 characters"`, + ); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/getShortCommitHash.ts b/plugins/git-release-manager/src/helpers/getShortCommitHash.ts new file mode 100644 index 0000000000..55241b390a --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getShortCommitHash.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export function getShortCommitHash(hash: string) { + const shortCommitHash = hash.substr(0, 7); + + if (shortCommitHash.length < 7) { + throw new GitReleaseManagerError( + 'Invalid shortCommitHash: less than 7 characters', + ); + } + + return shortCommitHash; +} diff --git a/plugins/git-release-manager/src/helpers/isCalverTagParts.test.ts b/plugins/git-release-manager/src/helpers/isCalverTagParts.test.ts new file mode 100644 index 0000000000..1f74f2fe40 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isCalverTagParts.test.ts @@ -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 { + 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)); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/isCalverTagParts.ts b/plugins/git-release-manager/src/helpers/isCalverTagParts.ts new file mode 100644 index 0000000000..5e6d8cfa9c --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isCalverTagParts.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Project } from '../contexts/ProjectContext'; +import { CalverTagParts } from './tagParts/getCalverTagParts'; + +export function isCalverTagParts( + project: Project, + _tagParts: unknown, +): _tagParts is CalverTagParts { + return project.versioningStrategy === 'calver'; +} diff --git a/plugins/git-release-manager/src/helpers/isProjectValid.test.ts b/plugins/git-release-manager/src/helpers/isProjectValid.test.ts new file mode 100644 index 0000000000..44cec0e571 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isProjectValid.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockSemverProject } from '../test-helpers/test-helpers'; +import { isProjectValid } from './isProjectValid'; + +describe('isProjectValid', () => { + it('should return true for valid project', () => { + const result = isProjectValid(mockSemverProject); + + expect(result).toEqual(true); + }); + + it('should return false for invalid project (undefined argument)', () => { + const result = isProjectValid(undefined); + + expect(result).toEqual(false); + }); + + it('should return false for invalid project (empty object argument)', () => { + const result = isProjectValid({}); + + expect(result).toEqual(false); + }); + + it('should return false for invalid project (invalid versioningStrategy argument)', () => { + const result = isProjectValid({ + ...mockSemverProject, + versioningStrategy: 'banana', + }); + + expect(result).toEqual(false); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/isProjectValid.ts b/plugins/git-release-manager/src/helpers/isProjectValid.ts new file mode 100644 index 0000000000..8a48ac5bbd --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isProjectValid.ts @@ -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 { Project } from '../contexts/ProjectContext'; + +export function isProjectValid(project: any): project is Project { + return ( + project?.owner?.length > 0 && + project?.repo?.length > 0 && + (['semver', 'calver'] as Project['versioningStrategy'][]).includes( + project?.versioningStrategy, + ) + ); +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.test.ts new file mode 100644 index 0000000000..c74438b63a --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { + mockReleaseVersionCalver, + mockReleaseCandidateCalver, +} from '../../test-helpers/test-helpers'; +import { getCalverTagParts } from './getCalverTagParts'; + +describe('getCalverTagParts', () => { + describe('happy path', () => { + it('should return tagParts for RC tag', () => { + const result = getCalverTagParts(mockReleaseCandidateCalver.tagName); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "calver": "2020.01.01", + "patch": 1, + "prefix": "rc", + }, + } + `); + }); + + it('should return tagParts for Version tag', () => { + const result = getCalverTagParts(mockReleaseVersionCalver.tagName); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "calver": "2020.01.01", + "patch": 1, + "prefix": "version", + }, + } + `); + }); + }); + + describe('invalid calver tags', () => { + it('should return error for invalid prefix', () => { + const result = getCalverTagParts('invalid-2020.01.01_1'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"invalid-2020.01.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid calver (missing padded zero)', () => { + const result = getCalverTagParts('rc-2020.1.01_1'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-2020.1.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid calver (missing day)', () => { + const result = getCalverTagParts('rc-2020.01_1'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-2020.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid patch (letter instead of number)', () => { + const result = getCalverTagParts('rc-2020.01.01_a'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-2020.01.01_a\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.ts new file mode 100644 index 0000000000..09f3b3e3d5 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.ts @@ -0,0 +1,50 @@ +/* + * 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 { AlertError } from '../../types/types'; + +export type CalverTagParts = { + prefix: string; + calver: string; + patch: number; +}; + +export const calverRegexp = /(rc|version)-([0-9]{4}\.[0-9]{2}\.[0-9]{2})_([0-9]+)/; + +export function getCalverTagParts(tag: string) { + const match = tag.match(calverRegexp); + + if (match === null || match.length < 4) { + const error: AlertError = { + title: 'Invalid tag', + subtitle: `Expected calver matching "${calverRegexp}", found "${tag}"`, + }; + + return { + error, + }; + } + + const tagParts: CalverTagParts = { + prefix: match[1], + calver: match[2], + patch: parseInt(match[3], 10), + }; + + return { + tagParts, + }; +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.test.ts new file mode 100644 index 0000000000..4a5e218eaf --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.test.ts @@ -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 { + mockReleaseCandidateSemver, + mockReleaseVersionSemver, +} from '../../test-helpers/test-helpers'; +import { getSemverTagParts } from './getSemverTagParts'; + +describe('getSemverTagParts', () => { + describe('happy path', () => { + it('should return tagParts for RC tag', () => { + const semverTagParts = getSemverTagParts( + mockReleaseCandidateSemver.tagName, + ); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "major": 1, + "minor": 2, + "patch": 3, + "prefix": "rc", + }, + } + `); + }); + + it('should return tagParts for Version tag', () => { + const semverTagParts = getSemverTagParts( + mockReleaseVersionSemver.tagName, + ); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "major": 1, + "minor": 2, + "patch": 3, + "prefix": "version", + }, + } + `); + }); + }); + + describe('invalid semver tags', () => { + it('should return error for invalid prefix', () => { + const semverTagParts = getSemverTagParts('invalid-1.2.3'); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found \\"invalid-1.2.3\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid semver (missing patch)', () => { + const semverTagParts = getSemverTagParts('rc-1.2'); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found \\"rc-1.2\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid semver (founds calver)', () => { + const semverTagParts = getSemverTagParts('rc-1337.01.01_1'); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found calver \\"rc-1337.01.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.ts new file mode 100644 index 0000000000..8f5d53f488 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlertError } from '../../types/types'; +import { calverRegexp } from './getCalverTagParts'; + +export type SemverTagParts = { + prefix: string; + major: number; + minor: number; + patch: number; +}; + +export const semverRegexp = /(rc|version)-([0-9]+)\.([0-9]+)\.([0-9]+)/; + +export function getSemverTagParts(tag: string) { + const match = tag.match(semverRegexp); + + if (match === null || match.length < 4) { + const error: AlertError = { + title: 'Invalid tag', + subtitle: `Expected semver matching "${semverRegexp}", found "${tag}"`, + }; + + return { + error, + }; + } + + if (tag.match(calverRegexp)) { + const error: AlertError = { + title: 'Invalid tag', + subtitle: `Expected semver matching "${semverRegexp}", found calver "${tag}"`, + }; + + return { + error, + }; + } + + const tagParts: SemverTagParts = { + prefix: match[1], + major: parseInt(match[2], 10), + minor: parseInt(match[3], 10), + patch: parseInt(match[4], 10), + }; + + return { + tagParts, + }; +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/getTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.test.ts new file mode 100644 index 0000000000..825d23c001 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { getCalverTagParts } from './getCalverTagParts'; +import { getSemverTagParts } from './getSemverTagParts'; +import { getTagParts } from './getTagParts'; + +jest.mock('./getCalverTagParts', () => ({ + getCalverTagParts: jest.fn(), +})); +jest.mock('./getSemverTagParts', () => ({ + getSemverTagParts: jest.fn(), +})); + +describe('getTagParts', () => { + beforeEach(jest.resetAllMocks); + + describe('calver', () => { + it('should call getCalverTagParts for calver projects', () => { + getTagParts({ project: mockCalverProject, tag: 'banana' }); + + expect(getCalverTagParts).toHaveBeenCalledTimes(1); + }); + }); + + describe('semver', () => { + it('should call getSemverTagParts for calver projects', () => { + getTagParts({ project: mockSemverProject, tag: 'banana' }); + + expect(getSemverTagParts).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts new file mode 100644 index 0000000000..a3f8665554 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts @@ -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 { getCalverTagParts } from './getCalverTagParts'; +import { getSemverTagParts } from './getSemverTagParts'; +import { Project } from '../../contexts/ProjectContext'; + +export function getTagParts({ + project, + tag, +}: { + project: Project; + tag: string; +}) { + if (project.versioningStrategy === 'calver') { + return getCalverTagParts(tag); + } + + return getSemverTagParts(tag); +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/validateTagName.ts b/plugins/git-release-manager/src/helpers/tagParts/validateTagName.ts new file mode 100644 index 0000000000..e7d6124c81 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/validateTagName.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getCalverTagParts } from './getCalverTagParts'; +import { getSemverTagParts } from './getSemverTagParts'; +import { Project } from '../../contexts/ProjectContext'; + +export const validateTagName = ({ + project, + tagName, +}: { + project: Project; + tagName?: string; +}) => { + if (!tagName) { + return { + tagNameError: null, + }; + } + + if (project.versioningStrategy === 'calver') { + const { error } = getCalverTagParts(tagName); + + return { + tagNameError: error, + }; + } + + const { error } = getSemverTagParts(tagName); + + return { + tagNameError: error, + }; +}; diff --git a/plugins/git-release-manager/src/helpers/tagParts/validateTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/validateTagParts.test.ts new file mode 100644 index 0000000000..9a32b1a806 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/validateTagParts.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCalverProject, + mockReleaseCandidateCalver, + mockReleaseCandidateSemver, + mockSemverProject, +} from '../../test-helpers/test-helpers'; +import { validateTagName } from './validateTagName'; + +describe('validateTagName', () => { + describe('valid tags', () => { + it('should not return any error for valid semver project', () => { + const result = validateTagName({ + project: mockSemverProject, + tagName: mockReleaseCandidateSemver.tagName, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": undefined, + } + `); + }); + + it('should not return any error for semver project without any releases (i.e. no tagName)', () => { + const result = validateTagName({ + project: mockSemverProject, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": null, + } + `); + }); + }); + + describe('mismatching tags', () => { + it('should return error for semver project and calver tag', () => { + const result = validateTagName({ + project: mockSemverProject, + tagName: mockReleaseCandidateCalver.tagName, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found calver \\"rc-2020.01.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for calver project and semver tag', () => { + const result = validateTagName({ + project: mockCalverProject, + tagName: mockReleaseCandidateSemver.tagName, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-1.2.3\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); + + describe('invalid tags', () => { + it('should return error for semver project and totally invalid tag', () => { + const result = validateTagName({ + project: mockSemverProject, + tagName: 'this-is-so-invalid', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found \\"this-is-so-invalid\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for calver project and totally invalid tag', () => { + const result = validateTagName({ + project: mockCalverProject, + tagName: 'this-is-so-invalid', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"this-is-so-invalid\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.test.ts b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.test.ts new file mode 100644 index 0000000000..3eae247cb9 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { mockApiClient, mockSemverProject } from '../test-helpers/test-helpers'; +import { useGetGitBatchInfo } from './useGetGitBatchInfo'; + +describe('useGetHubBatchInfo', () => { + it('should handle repositories with releases', async () => { + const { result } = renderHook(() => + useGetGitBatchInfo({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + }), + ); + + await act(async () => { + await waitFor(() => result.current.gitBatchInfo !== undefined); + }); + + expect(result.current.gitBatchInfo).toMatchInlineSnapshot(` + Object { + "loading": false, + "value": Object { + "latestRelease": Object { + "htmlUrl": "https://mock_release_html_url", + "id": 1, + "prerelease": false, + "tagName": "rc-2020.01.01_1", + "targetCommitish": "rc/2020.01.01_1", + }, + "releaseBranch": Object { + "commit": Object { + "commit": Object { + "tree": Object { + "sha": "mock_branch_commit_commit_tree_sha", + }, + }, + "sha": "mock_branch_commit_sha", + }, + "links": Object { + "html": "https://mock_branch_links_html", + }, + "name": "rc/1.2.3", + }, + "repository": Object { + "defaultBranch": "mock_defaultBranch", + "name": "mock_repo", + "pushPermissions": true, + }, + }, + } + `); + }); + + it('should handle repositories without any releases', async () => { + (mockApiClient.getLatestRelease as jest.Mock).mockResolvedValueOnce(null); + + const { result } = renderHook(() => + useGetGitBatchInfo({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + }), + ); + + await act(async () => { + await waitFor(() => result.current.gitBatchInfo !== undefined); + }); + + expect(result.current.gitBatchInfo).toMatchInlineSnapshot(` + Object { + "error": [TypeError: Cannot read property 'latestRelease' of null], + "loading": false, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.ts b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.ts new file mode 100644 index 0000000000..39935b7a6a --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { GitReleaseApi } from '../api/GitReleaseClient'; +import { Project } from '../contexts/ProjectContext'; + +interface GetGitBatchInfo { + project: Project; + pluginApiClient: GitReleaseApi; +} + +export const useGetGitBatchInfo = ({ + project, + pluginApiClient, +}: GetGitBatchInfo) => { + const [gitBatchInfo, fetchGitBatchInfo] = useAsyncFn(async () => { + const [{ repository }, { latestRelease }] = await Promise.all([ + pluginApiClient.getRepository({ + owner: project.owner, + repo: project.repo, + }), + pluginApiClient.getLatestRelease({ + owner: project.owner, + repo: project.repo, + }), + ]); + + if (latestRelease === null) { + return { + latestRelease, + releaseBranch: null, + repository, + }; + } + + const { branch: releaseBranch } = await pluginApiClient.getBranch({ + owner: project.owner, + repo: project.repo, + branch: latestRelease.targetCommitish, + }); + + return { + latestRelease, + releaseBranch, + repository, + }; + }); + + useEffect(() => { + fetchGitBatchInfo(); + }, [fetchGitBatchInfo, project]); + + return { + gitBatchInfo, + fetchGitBatchInfo, + }; +}; diff --git a/plugins/git-release-manager/src/hooks/useQueryHandler.test.tsx b/plugins/git-release-manager/src/hooks/useQueryHandler.test.tsx new file mode 100644 index 0000000000..4024e37054 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useQueryHandler.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { mockSearchSemver } from '../test-helpers/test-helpers'; +import { useQueryHandler } from './useQueryHandler'; + +jest.mock('react-router', () => ({ + useLocation: jest.fn(() => ({ + search: mockSearchSemver, + })), +})); + +const TEST_ID = 'grm--use-query-handler'; + +const MockComponent = () => { + const { getParsedQuery, getQueryParamsWithUpdates } = useQueryHandler(); + + const { parsedQuery } = getParsedQuery(); + const { queryParams } = getQueryParamsWithUpdates({ + updates: [{ key: 'repo', value: 'updated_mock_repo' }], + }); + + return ( +
+ {JSON.stringify({ parsedQuery, queryParams }, null, 2)} +
+ ); +}; + +describe('useQueryHandler', () => { + it('should get parsedQuery and queryParams', () => { + const { getByTestId } = render(); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toMatchInlineSnapshot(` + "{ + \\"parsedQuery\\": { + \\"versioningStrategy\\": \\"semver\\", + \\"owner\\": \\"mock_owner\\", + \\"repo\\": \\"mock_repo\\" + }, + \\"queryParams\\": \\"versioningStrategy=semver&owner=mock_owner&repo=updated_mock_repo\\" + }" + `); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useQueryHandler.ts b/plugins/git-release-manager/src/hooks/useQueryHandler.ts new file mode 100644 index 0000000000..c2b6e87a0f --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useQueryHandler.ts @@ -0,0 +1,68 @@ +/* + * 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 { useLocation } from 'react-router'; +import qs from 'qs'; + +import { Project } from '../contexts/ProjectContext'; + +export function useQueryHandler() { + const location = useLocation(); + + function getParsedQuery() { + const { decodedSearch } = getDecodedSearch(location); + const parsedQuery: Partial = qs.parse(decodedSearch); + + return { + parsedQuery, + }; + } + + function getQueryParamsWithUpdates({ + updates, + }: { + updates: { + key: keyof Project; + value: string; + }[]; + }) { + const { decodedSearch } = getDecodedSearch(location); + const queryParams = qs.parse(decodedSearch); + + for (const { key, value } of updates) { + queryParams[key] = value; + } + + return { + queryParams: qs.stringify(queryParams), + }; + } + + return { + getParsedQuery, + getQueryParamsWithUpdates, + }; +} + +function getDecodedSearch(location: ReturnType) { + return { + decodedSearch: new URLSearchParams(location.search).toString(), + }; +} + +export const testables = { + getDecodedSearch, +}; diff --git a/plugins/git-release-manager/src/hooks/useResponseSteps.test.ts b/plugins/git-release-manager/src/hooks/useResponseSteps.test.ts new file mode 100644 index 0000000000..4460487f06 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useResponseSteps.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useResponseSteps } from './useResponseSteps'; + +describe('useResponseSteps', () => { + it('should export expected variables', () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "abortIfError": [Function], + "addStepToResponseSteps": [Function], + "asyncCatcher": [Function], + "responseSteps": Array [], + } + `); + }); + + describe('addStepToResponseSteps', () => { + it('should add responseSteps to state', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + act(() => { + result.current.addStepToResponseSteps({ + message: 'totally added a messaage ✌🏼', + }); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "message": "totally added a messaage ✌🏼", + }, + ] + `); + }); + }); + + describe('asyncCatcher', () => { + it('should catch Errors and add as failure step, then throw', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + await act(async () => { + await new Promise((_, reject) => reject(new Error(':('))) + .catch(result.current.asyncCatcher) + .catch( + () => void 0, // swallow + ); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "icon": "failure", + "message": "Something went wrong 🔥", + "secondaryMessage": "Error message: :(", + }, + ] + `); + }); + + it('should catch unknown Errors and add as failure step, then throw', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + await act(async () => { + await new Promise((_, reject) => reject()) + .catch(result.current.asyncCatcher) + .catch( + () => void 0, // swallow + ); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "icon": "failure", + "message": "Something went wrong 🔥", + "secondaryMessage": "Error message: unknown", + }, + ] + `); + }); + }); + + describe('abortIfError', () => { + it('should throw if Error and add a failure step', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + act(() => { + try { + result.current.abortIfError(new Error('Das kaboom')); + } catch (error) { + // + } + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "icon": "failure", + "message": "Skipped due to error in previous step", + }, + ] + `); + }); + + it('should do nothing if not Error', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + act(() => { + result.current.abortIfError(undefined); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + }); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useResponseSteps.ts b/plugins/git-release-manager/src/hooks/useResponseSteps.ts new file mode 100644 index 0000000000..e20587d078 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useResponseSteps.ts @@ -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 { useState } from 'react'; + +import { ResponseStep } from '../types/types'; + +const RESPONSE_STEP_FAILURE_ABORT: ResponseStep = { + message: 'Skipped due to error in previous step', + icon: 'failure', +}; + +export function useResponseSteps() { + const [responseSteps, setResponseSteps] = useState([]); + + const addStepToResponseSteps = (responseStep: ResponseStep) => { + setResponseSteps([...responseSteps, responseStep]); + }; + + const asyncCatcher = (error: Error): never => { + const responseStepError: ResponseStep = { + message: 'Something went wrong 🔥', + secondaryMessage: `Error message: ${ + error?.message ? error.message : 'unknown' + }`, + icon: 'failure', + }; + + addStepToResponseSteps(responseStepError); + throw error; + }; + + const abortIfError = (error?: Error) => { + if (error) { + addStepToResponseSteps(RESPONSE_STEP_FAILURE_ABORT); + throw error; + } + }; + + return { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + }; +} diff --git a/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.test.tsx b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.test.tsx new file mode 100644 index 0000000000..0a7ba54794 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 { + mockReleaseVersionCalver, + mockReleaseVersionSemver, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { Project } from '../contexts/ProjectContext'; +import { useVersioningStrategyMatchesRepoTags } from './useVersioningStrategyMatchesRepoTags'; + +const TEST_ID = 'grm--use-versioning-strategy-matches-repo-tags'; +const MATCH = 'match ✅'; +const NO_MATCH = 'NO match ❌'; + +const MockComponent = ({ + project, + latestReleaseTagName, + repositoryName, +}: { + project: Project; + latestReleaseTagName?: string; + repositoryName?: string; +}) => { + const { versioningStrategyMatches } = useVersioningStrategyMatchesRepoTags({ + project, + latestReleaseTagName, + repositoryName, + }); + + return ( +
+ {versioningStrategyMatches ? MATCH : NO_MATCH} +
+ ); +}; + +describe('useVersioningStrategyMatchesRepoTags', () => { + it('should NOT match for missing latestReleaseTagName & repositoryName', () => { + const { getByTestId } = render( + , + ); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toEqual(NO_MATCH); + }); + + it('should NOT match for mismatching versioning strategies', () => { + const { getByTestId } = render( + , + ); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toEqual(NO_MATCH); + }); + + it('should match for matching repositories with same versioning strategy', () => { + const { getByTestId } = render( + , + ); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toEqual(MATCH); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.ts b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.ts new file mode 100644 index 0000000000..032a5280a1 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.ts @@ -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 { useEffect, useState } from 'react'; + +import { Project } from '../contexts/ProjectContext'; +import { getTagParts } from '../helpers/tagParts/getTagParts'; + +export const useVersioningStrategyMatchesRepoTags = ({ + project, + latestReleaseTagName, + repositoryName, +}: { + project: Project; + latestReleaseTagName?: string; + repositoryName?: string; +}) => { + const [versioningStrategyMatches, setVersioningStrategyMatches] = useState( + false, + ); + useEffect(() => { + setVersioningStrategyMatches(false); + + if (latestReleaseTagName) { + if (project.repo === repositoryName) { + const { error } = getTagParts({ project, tag: latestReleaseTagName }); + setVersioningStrategyMatches(error === undefined); + } + } + }, [latestReleaseTagName, project, repositoryName]); + + return { + versioningStrategyMatches, + }; +}; diff --git a/plugins/git-release-manager/src/index.ts b/plugins/git-release-manager/src/index.ts new file mode 100644 index 0000000000..38b6a4ebb3 --- /dev/null +++ b/plugins/git-release-manager/src/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { + gitReleaseManagerPlugin, + GitReleaseManagerPage, + gitReleaseManagerApiRef, +} from './plugin'; diff --git a/plugins/git-release-manager/src/plugin.test.ts b/plugins/git-release-manager/src/plugin.test.ts new file mode 100644 index 0000000000..a4882ceb9b --- /dev/null +++ b/plugins/git-release-manager/src/plugin.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as plugin from './plugin'; + +describe('git-release-manager', () => { + it('should export plugin & friends', () => { + expect(Object.keys(plugin)).toMatchInlineSnapshot(` + Array [ + "gitReleaseManagerApiRef", + "gitReleaseManagerPlugin", + "GitReleaseManagerPage", + ] + `); + }); +}); diff --git a/plugins/git-release-manager/src/plugin.ts b/plugins/git-release-manager/src/plugin.ts new file mode 100644 index 0000000000..6b15b79a9f --- /dev/null +++ b/plugins/git-release-manager/src/plugin.ts @@ -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 { + configApiRef, + createPlugin, + createApiFactory, + githubAuthApiRef, + createRoutableExtension, +} from '@backstage/core'; + +import { gitReleaseManagerApiRef } from './api/serviceApiRef'; +import { GitReleaseClient } from './api/GitReleaseClient'; +import { rootRouteRef } from './routes'; + +export { gitReleaseManagerApiRef }; + +export const gitReleaseManagerPlugin = createPlugin({ + id: 'git-release-manager', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: gitReleaseManagerApiRef, + deps: { + configApi: configApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ configApi, githubAuthApi }) => { + return new GitReleaseClient({ + configApi, + githubAuthApi, + }); + }, + }), + ], +}); + +export const GitReleaseManagerPage = gitReleaseManagerPlugin.provide( + createRoutableExtension({ + component: () => + import('./GitReleaseManager').then(m => m.GitReleaseManager), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/git-release-manager/src/routes.ts b/plugins/git-release-manager/src/routes.ts new file mode 100644 index 0000000000..3b3ea80cc2 --- /dev/null +++ b/plugins/git-release-manager/src/routes.ts @@ -0,0 +1,21 @@ +/* + * 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: 'git-release-manager', +}); diff --git a/plugins/git-release-manager/src/setupTests.ts b/plugins/git-release-manager/src/setupTests.ts new file mode 100644 index 0000000000..3ffe1424cc --- /dev/null +++ b/plugins/git-release-manager/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * 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'; diff --git a/plugins/git-release-manager/src/test-helpers/stats.ts b/plugins/git-release-manager/src/test-helpers/stats.ts new file mode 100644 index 0000000000..bd1a33f95a --- /dev/null +++ b/plugins/git-release-manager/src/test-helpers/stats.ts @@ -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 { ReleaseStats } from '../features/Stats/contexts/ReleaseStatsContext'; + +export const mockReleaseStats: ReleaseStats = { + releases: { + '1.0': { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.1', + tagSha: 'sha-1.0.1', + tagType: 'tag', + }, + { + tagName: 'rc-1.0.0', + tagSha: 'sha-1.0.0', + tagType: 'tag', + }, + ], + versions: [], + }, + '1.1': { + baseVersion: '1.1', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.1.2', + tagSha: 'sha-1.1.2', + tagType: 'tag', + }, + { + tagName: 'rc-1.1.1', + tagSha: 'sha-1.1.1', + tagType: 'tag', + }, + { + tagName: 'rc-1.1.0', + tagSha: 'sha-1.1.0', + tagType: 'tag', + }, + ], + versions: [ + { + tagName: 'version-1.1.3', + tagSha: 'sha-1.1.3', + tagType: 'tag', + }, + { + tagName: 'version-1.1.2', + tagSha: 'sha-1.1.2', + tagType: 'tag', + }, + ], + }, + }, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], +}; diff --git a/plugins/git-release-manager/src/test-helpers/test-helpers.ts b/plugins/git-release-manager/src/test-helpers/test-helpers.ts new file mode 100644 index 0000000000..09c90d3f76 --- /dev/null +++ b/plugins/git-release-manager/src/test-helpers/test-helpers.ts @@ -0,0 +1,359 @@ +/* + * 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 { + GetBranchResult, + GetLatestReleaseResult, + GetRecentCommitsResultSingle, + GetTagResult, + GitReleaseApi, + GetCommitResult, +} from '../api/GitReleaseClient'; +import { CalverTagParts } from '../helpers/tagParts/getCalverTagParts'; +import { Project } from '../contexts/ProjectContext'; +import { getReleaseCandidateGitInfo } from '../helpers/getReleaseCandidateGitInfo'; + +const mockUsername = 'mock_username'; +const mockEmail = 'mock_email'; +const mockOwner = 'mock_owner'; +const mockRepo = 'mock_repo'; + +const A_CALVER_VERSION = '2020.01.01_1'; +const MOCK_RELEASE_NAME_CALVER = `Version ${A_CALVER_VERSION}`; +const MOCK_RELEASE_BRANCH_NAME_CALVER = `rc/${A_CALVER_VERSION}`; +const MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER = `rc-${A_CALVER_VERSION}`; +const MOCK_RELEASE_VERSION_TAG_NAME_CALVER = `version-${A_CALVER_VERSION}`; + +const A_SEMVER_VERSION = '1.2.3'; +const MOCK_RELEASE_NAME_SEMVER = `Version ${A_SEMVER_VERSION}`; +const MOCK_RELEASE_BRANCH_NAME_SEMVER = `rc/${A_SEMVER_VERSION}`; +const MOCK_RELEASE_CANDIDATE_TAG_NAME_SEMVER = `rc-${A_SEMVER_VERSION}`; +const MOCK_RELEASE_VERSION_TAG_NAME_SEMVER = `version-${A_SEMVER_VERSION}`; + +export const createMockTag = ( + overrides: Partial, +): GetTagResult => ({ + tag: { + date: '2000-01-01T10:00:00.000Z', + objectSha: 'mock_tag_object_sha', + userEmail: mockEmail, + username: mockUsername, + ...overrides, + }, +}); + +export const createMockCommit = ( + overrides: Partial, +): GetCommitResult => ({ + commit: { + commit: { + message: 'mock_commit_commit_message', + }, + htmlUrl: 'https://mock_commit_html_url', + sha: 'mock_commit_sha', + createdAt: '2000-01-01T10:00:00.000Z', + ...overrides, + }, +}); + +export const mockUser = { + username: mockUsername, + email: mockEmail, +}; + +export const mockSemverProject: Project = { + owner: mockOwner, + repo: mockRepo, + versioningStrategy: 'semver', + isProvidedViaProps: false, +}; + +export const mockCalverProject: Project = { + owner: mockOwner, + repo: mockRepo, + versioningStrategy: 'calver', + isProvidedViaProps: false, +}; + +export const mockSearchCalver = `?versioningStrategy=${mockCalverProject.versioningStrategy}&owner=${mockCalverProject.owner}&repo=${mockCalverProject.repo}`; + +export const mockSearchSemver = `?versioningStrategy=${mockSemverProject.versioningStrategy}&owner=${mockSemverProject.owner}&repo=${mockSemverProject.repo}`; + +export const mockDefaultBranch = 'mock_defaultBranch'; + +export const mockNextGitInfoSemver: ReturnType< + typeof getReleaseCandidateGitInfo +> = { + rcBranch: MOCK_RELEASE_BRANCH_NAME_SEMVER, + rcReleaseTag: MOCK_RELEASE_CANDIDATE_TAG_NAME_SEMVER, + releaseName: MOCK_RELEASE_NAME_SEMVER, +}; + +export const mockNextGitInfoCalver: ReturnType< + typeof getReleaseCandidateGitInfo +> = { + rcBranch: MOCK_RELEASE_BRANCH_NAME_CALVER, + rcReleaseTag: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + releaseName: MOCK_RELEASE_NAME_CALVER, +}; + +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< + NonNullable +> = {}): NonNullable => ({ + id, + htmlUrl: 'https://mock_release_html_url', + prerelease, + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_CALVER, + ...rest, +}); + +export const mockReleaseCandidateCalver = createMockRelease({ + prerelease: true, + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_CALVER, +}); + +export const mockReleaseVersionCalver = createMockRelease({ + prerelease: false, + tagName: MOCK_RELEASE_VERSION_TAG_NAME_CALVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_CALVER, +}); + +export const mockReleaseCandidateSemver = createMockRelease({ + prerelease: true, + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_SEMVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_SEMVER, +}); +export const mockReleaseVersionSemver = createMockRelease({ + prerelease: false, + tagName: MOCK_RELEASE_VERSION_TAG_NAME_SEMVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_SEMVER, +}); + +/** + * MOCK BRANCH + */ +const createMockBranch = ({ + ...rest +}: Partial = {}): GetBranchResult['branch'] => ({ + name: MOCK_RELEASE_BRANCH_NAME_SEMVER, + commit: { + sha: 'mock_branch_commit_sha', + commit: { + tree: { + sha: 'mock_branch_commit_commit_tree_sha', + }, + }, + }, + links: { + html: 'https://mock_branch_links_html', + }, + ...rest, +}); +export const mockReleaseBranch = createMockBranch(); + +/** + * MOCK COMMIT + */ +const createMockRecentCommit = ({ + ...rest +}: Partial): GetRecentCommitsResultSingle => ({ + author: { + htmlUrl: 'https://author_html_url', + login: 'author_login', + }, + commit: { + message: 'commit_message', + }, + sha: 'mock_sha', + firstParentSha: 'mock_first_parent_sha', + htmlUrl: 'https://mock_htmlUrl', + ...rest, +}); + +export const mockSelectedPatchCommit = createMockRecentCommit({ + sha: 'mock_sha_selected_patch_commit', +}); + +/** + * MOCK API CLIENT + */ +export const mockApiClient: GitReleaseApi = { + getHost: jest.fn(() => 'github.com'), + + getRepoPath: jest.fn(() => `${mockOwner}/${mockRepo}`), + + getOwners: jest.fn(async () => ({ + owners: [mockOwner, `${mockOwner}2`], + })), + + getRepositories: jest.fn(async () => ({ + repositories: [mockRepo, `${mockRepo}2`], + })), + + getUser: jest.fn(async () => ({ + user: { + username: mockOwner, + email: mockEmail, + }, + })), + + getRecentCommits: jest.fn(async () => ({ + recentCommits: [ + createMockRecentCommit({ sha: 'mock_sha_recent_commits_1' }), + createMockRecentCommit({ sha: 'mock_sha_recent_commits_2' }), + ], + })), + + getLatestRelease: jest.fn(async () => ({ + latestRelease: createMockRelease(), + })), + + getRepository: jest.fn(async () => ({ + repository: { + pushPermissions: true, + defaultBranch: mockDefaultBranch, + name: mockRepo, + }, + })), + + getCommit: jest.fn(async () => ({ + commit: { + sha: 'latestCommit.sha', + htmlUrl: 'https://latestCommit.html_url', + commit: { + message: 'latestCommit.commit.message', + }, + createdAt: '2021-01-01T10:11:12Z', + }, + })), + + getBranch: jest.fn(async () => ({ + branch: createMockBranch(), + })), + + createRef: jest.fn(async () => ({ + reference: { + ref: 'mock_createRef_ref', + objectSha: 'mock_createRef_objectSha', + }, + })), + + createRelease: jest.fn(async () => ({ + release: { + name: 'mock_createRelease_name', + htmlUrl: 'https://mock_createRelease_html_url', + tagName: 'mock_createRelease_tag_name', + }, + })), + + getComparison: jest.fn(async () => ({ + comparison: { + htmlUrl: 'https://mock_compareCommits_html_url', + aheadBy: 1, + }, + })), + + createTagObject: jest.fn(async () => ({ + tagObject: { + tagName: 'mock_tag_object_tag', + tagSha: 'mock_tag_object_sha', + }, + })), + + createCommit: jest.fn(async () => ({ + commit: { + message: 'mock_commit_message', + sha: 'mock_commit_sha', + }, + })), + + updateRef: jest.fn(async () => ({ + reference: { + ref: 'mock_update_ref_ref', + object: { + sha: 'mock_update_ref_object_sha', + }, + }, + })), + + merge: jest.fn(async () => ({ + merge: { + htmlUrl: 'https://mock_merge_html_url', + commit: { + message: 'mock_merge_commit_message', + tree: { + sha: 'mock_merge_commit_tree_sha', + }, + }, + }, + })), + + updateRelease: jest.fn(async () => ({ + release: { + name: 'mock_update_release_name', + tagName: 'mock_update_release_tag_name', + htmlUrl: 'https://mock_update_release_html_url', + }, + })), + + getAllTags: jest.fn(async () => ({ + tags: [ + { + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + tagSha: 'mock_sha', + tagType: 'tag' as const, + }, + ], + })), + + getAllReleases: jest.fn(async () => ({ + releases: [ + { + id: 1, + name: 'mock_release_name', + tagName: 'mock_release_tag_name', + createdAt: 'mock_release_published_at', + htmlUrl: 'https://mock_release_html_url', + }, + ], + })), + + getTag: jest.fn(async () => ({ + tag: { + date: '2021-04-29T12:48:30.120Z', + username: 'mock_user_single_tag_name', + userEmail: 'mock_user_single_tag_email', + objectSha: 'mock_single_tag_object_sha', + }, + })), +}; diff --git a/plugins/git-release-manager/src/test-helpers/test-ids.ts b/plugins/git-release-manager/src/test-helpers/test-ids.ts new file mode 100644 index 0000000000..5e4175ba4d --- /dev/null +++ b/plugins/git-release-manager/src/test-helpers/test-ids.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const TEST_IDS = { + info: { + info: 'grm--info', + infoFeaturePlus: 'grm--info-feature-plus', + }, + createRc: { + cta: 'grm--create-rc--cta', + semverSelect: 'grm--create-rc--semver-select', + }, + promoteRc: { + mockedPromoteRcBody: 'grm-mocked-promote-rc-body', + notRcWarning: 'grm--promote-rc--not-rc-warning', + promoteRc: 'grm--promote-rc', + cta: 'grm--promote-rc-body--cta', + }, + patch: { + error: 'grm--patch-body--error', + loading: 'grm--patch-body--loading', + notPrerelease: 'grm--patch-body--not-prerelease--info', + body: 'grm--patch-body', + }, + form: { + owner: { + loading: 'grm--form--owner--loading', + select: 'grm--form--owner--select', + error: 'grm--form--owner--error', + empty: 'grm--form--owner--empty', + }, + repo: { + loading: 'grm--form--repo--loading', + select: 'grm--form--repo--select', + error: 'grm--form--repo--error', + empty: 'grm--form--repo--empty', + }, + versioningStrategy: { + radioGroup: 'grm--form--versioning-strategy--radio-group', + }, + }, + components: { + divider: 'grm--divider', + noLatestRelease: 'grm--no-latest-release', + circularProgress: 'grm--circular-progress', + responseStepListDialogContent: 'grm--response-step-list--dialog-content', + responseStepListItem: 'grm--response-step-list-item', + responseStepListItemIconSuccess: + 'grm--response-step-list-item--item-icon--success', + responseStepListItemIconFailure: + 'grm--response-step-list-item--item-icon--failure', + responseStepListItemIconLink: + 'grm--response-step-list-item--item-icon--link', + responseStepListItemIconDefault: + 'grm--response-step-list-item--item-icon--default', + differ: { + current: 'grm--differ-current', + next: 'grm--differ-next', + icons: { + tag: 'grm--differ--icons--tag', + branch: 'grm--differ--icons--branch', + github: 'grm--differ--icons--git', + slack: 'grm--differ--icons--slack', + versioning: 'grm--differ--icons--versioning', + }, + }, + linearProgressWithLabel: 'grm--linear-progress-with-label', + }, +}; diff --git a/plugins/git-release-manager/src/types/helpers.ts b/plugins/git-release-manager/src/types/helpers.ts new file mode 100644 index 0000000000..55bc6fdcac --- /dev/null +++ b/plugins/git-release-manager/src/types/helpers.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type UnboxPromise> = T extends Promise + ? U + : never; + +export type UnboxReturnedPromise< + T extends (...args: any) => Promise +> = UnboxPromise>; + +export type UnboxArray = T extends (infer U)[] ? U : T; diff --git a/plugins/git-release-manager/src/types/types.ts b/plugins/git-release-manager/src/types/types.ts new file mode 100644 index 0000000000..5feac149ca --- /dev/null +++ b/plugins/git-release-manager/src/types/types.ts @@ -0,0 +1,68 @@ +/* + * 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 type ComponentConfig = { + omit?: boolean; + onSuccess?: (args: Args) => Promise | void; +}; + +interface CreateRcOnSuccessArgs { + gitReleaseUrl: string; + gitReleaseName: string | null; + comparisonUrl: string; + previousTag?: string; + createdTag: string; +} +export type ComponentConfigCreateRc = ComponentConfig; + +interface PromoteRcOnSuccessArgs { + gitReleaseUrl: string; + gitReleaseName: string | null; + previousTagUrl: string; + previousTag: string; + updatedTagUrl: string; + updatedTag: string; +} +export type ComponentConfigPromoteRc = ComponentConfig; + +interface PatchOnSuccessArgs { + updatedReleaseUrl: string; + updatedReleaseName: string | null; + previousTag: string; + patchedTag: string; + patchCommitUrl: string; + patchCommitMessage: string; +} +export type ComponentConfigPatch = ComponentConfig; + +export interface ResponseStep { + message: string | React.ReactNode; + secondaryMessage?: string | React.ReactNode; + link?: string; + icon?: 'success' | 'failure'; +} + +export interface CardHook { + progress: number; + responseSteps: ResponseStep[]; + run: (args: RunArgs) => Promise; + runInvoked: boolean; +} + +export interface AlertError { + title?: string; + subtitle: string; +} diff --git a/yarn.lock b/yarn.lock index 23c8118e7e..437a9be678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21663,7 +21663,7 @@ qs@6.7.0: resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.5.1: +qs@^6.10.1, qs@^6.5.1: version "6.10.1" resolved "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==