diff --git a/plugins/git-release-manager/src/api/GitReleaseApiClient.test.ts b/plugins/git-release-manager/src/api/GitReleaseApiClient.test.ts index b37500451f..215c615963 100644 --- a/plugins/git-release-manager/src/api/GitReleaseApiClient.test.ts +++ b/plugins/git-release-manager/src/api/GitReleaseApiClient.test.ts @@ -33,10 +33,11 @@ describe('GitReleaseApiClient', () => { GitReleaseApiClient { "baseUrl": "https://api.github.com", "createRc": Object { - "createRef": [Function], "createRelease": [Function], "getComparison": [Function], }, + "createRef": [Function], + "createTagObject": [Function], "getBranch": [Function], "getHost": [Function], "getLatestCommit": [Function], @@ -54,7 +55,6 @@ describe('GitReleaseApiClient', () => { "patch": Object { "createCherryPickCommit": [Function], "createReference": [Function], - "createTagObject": [Function], "createTempCommit": [Function], "forceBranchHeadToTempCommit": [Function], "merge": [Function], diff --git a/plugins/git-release-manager/src/api/GitReleaseApiClient.ts b/plugins/git-release-manager/src/api/GitReleaseApiClient.ts index 22762ac812..4a837a02e1 100644 --- a/plugins/git-release-manager/src/api/GitReleaseApiClient.ts +++ b/plugins/git-release-manager/src/api/GitReleaseApiClient.ts @@ -247,21 +247,22 @@ export class GitReleaseApiClient implements GitReleaseApi { }; }; + createRef: GitReleaseApi['createRef'] = async ({ owner, repo, sha, ref }) => { + const { octokit } = await this.getOctokit(); + const createRefResponse = await octokit.git.createRef({ + owner, + repo, + ref, + sha, + }); + + return { + ref: createRefResponse.data.ref, + objectSha: createRefResponse.data.object.sha, + }; + }; + createRc: GitReleaseApi['createRc'] = { - createRef: async ({ owner, repo, mostRecentSha, targetBranch }) => { - const { octokit } = await this.getOctokit(); - const createRefResponse = await octokit.git.createRef({ - owner, - repo, - ref: `refs/heads/${targetBranch}`, - sha: mostRecentSha, - }); - - return { - ref: createRefResponse.data.ref, - }; - }, - getComparison: async ({ owner, repo, base, head }) => { const { octokit } = await this.getOctokit(); const compareCommitsResponse = await octokit.repos.compareCommits({ @@ -304,6 +305,36 @@ export class GitReleaseApiClient implements GitReleaseApi { }, }; + createTagObject: GitReleaseApi['createTagObject'] = async ({ + owner, + repo, + tag, + objectSha, + taggerName, + taggerEmail, + message, + }) => { + const { octokit } = await this.getOctokit(); + const { data: createdTagObject } = await octokit.git.createTag({ + owner, + repo, + message, + tag, + object: objectSha, + type: 'commit', + tagger: { + date: new Date().toISOString(), + email: taggerEmail, + name: taggerName, + }, + }); + + return { + tagName: createdTagObject.tag, + tagSha: createdTagObject.sha, + }; + }; + patch: GitReleaseApi['patch'] = { createTempCommit: async ({ owner, @@ -411,31 +442,13 @@ ${selectedPatchCommit.sha}`, }; }, - createTagObject: async ({ owner, repo, tag, objectSha }) => { - const { octokit } = await this.getOctokit(); - const { data: createdTagObject } = await octokit.git.createTag({ - owner, - repo, - message: - 'Tag generated by your friendly neighborhood Backstage Release Manager', - tag, - object: objectSha, - type: 'commit', - }); - - return { - tag: createdTagObject.tag, - sha: createdTagObject.sha, - }; - }, - createReference: async ({ owner, repo, bumpedTag, createdTagObject }) => { const { octokit } = await this.getOctokit(); const { data: reference } = await octokit.git.createRef({ owner, repo, ref: `refs/tags/${bumpedTag}`, - sha: createdTagObject.sha, + sha: createdTagObject.tagSha, }); return { @@ -652,16 +665,17 @@ export interface GitReleaseApi { }; }>; - createRc: { - createRef: ( - args: { - mostRecentSha: string; - targetBranch: string; - } & OwnerRepo, - ) => Promise<{ + createRef: ( + args: { ref: string; - }>; + sha: string; + } & OwnerRepo, + ) => Promise<{ + ref: string; + objectSha: string; + }>; + createRc: { getComparison: ( args: { base: string; @@ -685,6 +699,20 @@ export interface GitReleaseApi { tagName: string; }>; }; + + createTagObject: ( + args: { + tag: string; + taggerEmail?: string; + message: string; + objectSha: string; + taggerName: string; + } & OwnerRepo, + ) => Promise<{ + tagName: string; + tagSha: string; + }>; + patch: { createTempCommit: ( args: { @@ -749,16 +777,6 @@ export interface GitReleaseApi { }; }>; - createTagObject: ( - args: { - tag: string; - objectSha: string; - } & OwnerRepo, - ) => Promise<{ - tag: string; - sha: string; - }>; - createReference: ( args: { bumpedTag: string; @@ -854,9 +872,7 @@ export type GetLatestCommitResult = UnboxReturnedPromise< GitReleaseApi['getLatestCommit'] >; export type GetBranchResult = UnboxReturnedPromise; -export type CreateRefResult = UnboxReturnedPromise< - GitReleaseApi['createRc']['createRef'] ->; +export type CreateRefResult = UnboxReturnedPromise; export type GetComparisonResult = UnboxReturnedPromise< GitReleaseApi['createRc']['getComparison'] >; @@ -877,7 +893,7 @@ export type ReplaceTempCommitResult = UnboxReturnedPromise< GitReleaseApi['patch']['replaceTempCommit'] >; export type CreateTagObjectResult = UnboxReturnedPromise< - GitReleaseApi['patch']['createTagObject'] + GitReleaseApi['createTagObject'] >; export type CreateReferenceResult = UnboxReturnedPromise< GitReleaseApi['patch']['createReference'] @@ -897,3 +913,6 @@ export type GetCommitResult = UnboxReturnedPromise< export type GetAllReleasesResult = UnboxReturnedPromise< GitReleaseApi['stats']['getAllReleases'] >; +export type GetSingleTagResult = UnboxReturnedPromise< + GitReleaseApi['stats']['getSingleTag'] +>; diff --git a/plugins/git-release-manager/src/constants/constants.test.ts b/plugins/git-release-manager/src/constants/constants.test.ts index cf2b7d7548..759c2c7ab2 100644 --- a/plugins/git-release-manager/src/constants/constants.test.ts +++ b/plugins/git-release-manager/src/constants/constants.test.ts @@ -30,6 +30,7 @@ describe('constants', () => { "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 index 195a4b80af..099a5913ca 100644 --- a/plugins/git-release-manager/src/constants/constants.ts +++ b/plugins/git-release-manager/src/constants/constants.ts @@ -37,3 +37,6 @@ export const VERSIONING_STRATEGIES: { 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/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx index 2acffbf52c..b4551083a3 100644 --- a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx @@ -23,6 +23,7 @@ import { mockDefaultBranch, mockNextGitInfoCalver, mockReleaseVersionCalver, + mockUser, } from '../../../test-helpers/test-helpers'; import { useCreateReleaseCandidate } from './useCreateReleaseCandidate'; @@ -30,6 +31,9 @@ jest.mock('@backstage/core', () => ({ useApi: () => mockApiClient, createApiRef: jest.fn(), })); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); describe('useCreateReleaseCandidate', () => { beforeEach(jest.clearAllMocks); @@ -49,7 +53,7 @@ describe('useCreateReleaseCandidate', () => { }); expect(result.error).toEqual(undefined); - expect(result.current.responseSteps).toHaveLength(4); + expect(result.current.responseSteps).toHaveLength(6); }); it('should return the expected responseSteps and progress (with successCb)', async () => { @@ -67,7 +71,7 @@ describe('useCreateReleaseCandidate', () => { await waitFor(() => result.current.run()); }); - expect(result.current.responseSteps).toHaveLength(5); + expect(result.current.responseSteps).toHaveLength(7); expect(result.current).toMatchInlineSnapshot(` Object { "progress": 100, @@ -78,7 +82,15 @@ describe('useCreateReleaseCandidate', () => { "secondaryMessage": "with message \\"latestCommit.commit.message\\"", }, Object { - "message": "Cut Release Branch", + "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 { diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts index ca203a1d10..3e6967ff37 100644 --- a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts @@ -27,7 +27,9 @@ import { getReleaseCandidateGitInfo } from '../../../helpers/getReleaseCandidate 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['defaultBranch']; @@ -45,6 +47,7 @@ export function useCreateReleaseCandidate({ successCb, }: UseCreateReleaseCandidate): CardHook { const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); if (releaseCandidateGitInfo.error) { throw new GitReleaseManagerError( @@ -87,18 +90,18 @@ export function useCreateReleaseCandidate({ }); /** - * (2) Create a new ref based on the default branch's most recent sha + * (2) Create release branch based on default branch's most recent sha */ - const createRcRes = useAsync(async () => { + const releaseBranchRes = useAsync(async () => { abortIfError(latestCommitRes.error); if (!latestCommitRes.value) return undefined; - const createdRef = await pluginApiClient.createRc + const createdReleaseBranch = await pluginApiClient .createRef({ owner: project.owner, repo: project.repo, - mostRecentSha: latestCommitRes.value.latestCommit.sha, - targetBranch: releaseCandidateGitInfo.rcBranch, + sha: latestCommitRes.value.latestCommit.sha, + ref: `refs/heads/${releaseCandidateGitInfo.rcBranch}`, }) .catch(error => { if (error?.body?.message === 'Reference already exists') { @@ -111,17 +114,80 @@ export function useCreateReleaseCandidate({ .catch(asyncCatcher); addStepToResponseSteps({ - message: 'Cut Release Branch', + 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 createdTagObject = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: releaseCandidateGitInfo.rcReleaseTag, + objectSha: releaseBranchRes.value.objectSha, + taggerName: user.username, + taggerEmail: user.email, + message: TAG_OBJECT_MESSAGE, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Tag Object', + secondaryMessage: `with sha "${createdTagObject.tagSha}"`, + }); + + return { + ...createdTagObject, + }; + }, [releaseBranchRes.value, releaseBranchRes.error]); + + /** + * (4) Create reference for tag object + */ + const createRcRes = useAsync(async () => { + abortIfError(tagObjectRes.error); + if (!tagObjectRes.value) return undefined; + + const 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, }; - }, [latestCommitRes.value, latestCommitRes.error]); + }, [tagObjectRes.value, tagObjectRes.error]); /** - * (3) Compose a body for the release + * (5) Compose a body for the release */ const getComparisonRes = useAsync(async () => { abortIfError(createRcRes.error); @@ -163,7 +229,7 @@ export function useCreateReleaseCandidate({ }, [createRcRes.value, createRcRes.error]); /** - * (4) Creates the Git Release itself + * (6) Creates the Git Release itself */ const createReleaseRes = useAsync(async () => { abortIfError(getComparisonRes.error); @@ -192,7 +258,7 @@ export function useCreateReleaseCandidate({ }, [getComparisonRes.value, getComparisonRes.error]); /** - * (5) Run successCb if defined + * (7) Run successCb if defined */ useAsync(async () => { if (successCb && !!createReleaseRes.value && !!getComparisonRes.value) { @@ -217,7 +283,7 @@ export function useCreateReleaseCandidate({ } }, [createReleaseRes.value]); - const TOTAL_STEPS = 4 + (!!successCb ? 1 : 0); + const TOTAL_STEPS = 6 + (!!successCb ? 1 : 0); const [progress, setProgress] = useState(0); useEffect(() => { setProgress((responseSteps.length / TOTAL_STEPS) * 100); 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 index 5fe1614e42..9741244c23 100644 --- a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts @@ -24,6 +24,7 @@ import { mockReleaseVersionCalver, mockSelectedPatchCommit, mockTagParts, + mockUser, } from '../../../test-helpers/test-helpers'; import { usePatch } from './usePatch'; @@ -31,6 +32,9 @@ jest.mock('@backstage/core', () => ({ useApi: () => mockApiClient, createApiRef: jest.fn(), })); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); describe('patch', () => { beforeEach(jest.clearAllMocks); diff --git a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts index d188e2bf51..7ae8ed920b 100644 --- a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts @@ -28,6 +28,8 @@ import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; import { Project } from '../../../contexts/ProjectContext'; import { SemverTagParts } from '../../../helpers/tagParts/getSemverTagParts'; import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useUserContext } from '../../../contexts/UserContext'; interface Patch { bumpedTag: string; @@ -46,6 +48,7 @@ export function usePatch({ successCb, }: Patch): CardHook { const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); const { responseSteps, addStepToResponseSteps, @@ -234,18 +237,21 @@ export function usePatch({ abortIfError(updatedRefRes.error); if (!updatedRefRes.value) return undefined; - const createdTagObject = await pluginApiClient.patch + const createdTagObject = await pluginApiClient .createTagObject({ owner: project.owner, repo: project.repo, tag: bumpedTag, objectSha: 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 "${createdTagObject.tag}"`, + secondaryMessage: `with name "${createdTagObject.tagName}"`, }); return { @@ -272,7 +278,7 @@ export function usePatch({ addStepToResponseSteps({ message: `Created new reference "${reference.ref}"`, - secondaryMessage: `for tag object "${createdTagObjRes.value.tag}"`, + secondaryMessage: `for tag object "${createdTagObjRes.value.tagName}"`, }); return { diff --git a/plugins/git-release-manager/src/test-helpers/test-helpers.ts b/plugins/git-release-manager/src/test-helpers/test-helpers.ts index 56b1d0214f..47a04e62ff 100644 --- a/plugins/git-release-manager/src/test-helpers/test-helpers.ts +++ b/plugins/git-release-manager/src/test-helpers/test-helpers.ts @@ -40,6 +40,11 @@ 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 mockUser = { + username: mockOwner, + email: mockEmail, +}; + export const mockSemverProject: Project = { owner: mockOwner, repo: mockRepo, @@ -214,11 +219,12 @@ export const mockApiClient: GitReleaseApi = { getBranch: jest.fn(async () => createMockBranch()), - createRc: { - createRef: jest.fn(async () => ({ - ref: 'mock_createRef_ref', - })), + createRef: jest.fn(async () => ({ + ref: 'mock_createRef_ref', + objectSha: 'mock_createRef_objectSha', + })), + createRc: { createRelease: jest.fn(async () => ({ name: 'mock_createRelease_name', htmlUrl: 'mock_createRelease_html_url', @@ -231,6 +237,11 @@ export const mockApiClient: GitReleaseApi = { })), }, + createTagObject: jest.fn(async () => ({ + tagName: 'mock_tag_object_tag', + tagSha: 'mock_tag_object_sha', + })), + patch: { createCherryPickCommit: jest.fn(async () => ({ message: 'mock_cherrypick_message', @@ -241,11 +252,6 @@ export const mockApiClient: GitReleaseApi = { ref: 'mock_reference_ref', })), - createTagObject: jest.fn(async () => ({ - tag: 'mock_tag_object_tag', - sha: 'mock_tag_object_sha', - })), - createTempCommit: jest.fn(async () => ({ message: 'mock_commit_message', sha: 'mock_commit_sha',