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 338fa2a2d1..e331215793 100644 --- a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts @@ -15,7 +15,7 @@ */ import { useEffect, useState } from 'react'; -import { useAsync, useAsyncFn } from 'react-use'; +import { useAsync } from 'react-use'; import { GetLatestReleaseResult, GetRecentCommitsResultSingle, @@ -32,9 +32,9 @@ 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'; import { useApi } from '@backstage/core-plugin-api'; +import { usePatchPreparation } from './usePatchPrep'; export interface UsePatch { bumpedTag: string; @@ -54,38 +54,53 @@ export function usePatch({ }: UsePatch): CardHook { const pluginApiClient = useApi(gitReleaseManagerApiRef); const { user } = useUserContext(); - const { responseSteps, addStepToResponseSteps, asyncCatcher, abortIfError } = - useResponseSteps(); const releaseBranchName = latestRelease.targetCommitish; + const { + run, + runInvoked, + lastCallRes, + abortIfError, + addStepToResponseSteps, + asyncCatcher, + responseSteps, + TOTAL_PATCH_PREP_STEPS, + selectedPatchCommit, + } = usePatchPreparation({ + bumpedTag, + releaseBranchName, + project, + tagParts, + }); + /** * (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); + const releaseBranchRes = useAsync(async () => { + abortIfError(lastCallRes.error); + if (!lastCallRes.value) return undefined; - addStepToResponseSteps({ - message: `Fetched release branch "${releaseBranch.name}"`, - link: releaseBranch.links.html, - }); + const { branch: releaseBranch } = await pluginApiClient + .getBranch({ + owner: project.owner, + repo: project.repo, + branch: releaseBranchName, + }) + .catch(asyncCatcher); - return { - releaseBranch, - selectedPatchCommit, - }; - }, - ); + addStepToResponseSteps({ + message: `Fetched release branch "${releaseBranch.name}"`, + link: releaseBranch.links.html, + }); + + return { + releaseBranch, + }; + }, [lastCallRes.value, lastCallRes.error]); /** * (2) Create a temporary commit on the branch, which extends as a sibling of @@ -102,9 +117,7 @@ export function usePatch({ owner: project.owner, repo: project.repo, message: `Temporary commit for patch ${tagParts.patch}`, - parents: [ - releaseBranchRes.value.selectedPatchCommit.firstParentSha ?? '', - ], + parents: [selectedPatchCommit.firstParentSha ?? ''], tree: releaseBranchRes.value.releaseBranch.commit.commit.tree.sha, }) .catch(asyncCatcher); @@ -159,7 +172,7 @@ export function usePatch({ owner: project.owner, repo: project.repo, base: releaseBranchName, - head: releaseBranchRes.value.selectedPatchCommit.sha, + head: selectedPatchCommit.sha, }) .catch(asyncCatcher); @@ -184,7 +197,6 @@ export function usePatch({ 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, @@ -299,8 +311,6 @@ export function usePatch({ abortIfError(createdReferenceRes.error); if (!createdReferenceRes.value || !releaseBranchRes.value) return undefined; - const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit; - const { release } = await pluginApiClient .updateRelease({ owner: project.owner, @@ -343,16 +353,15 @@ ${selectedPatchCommit.commit.message}`, project, tagParts, }, - patchCommitMessage: - releaseBranchRes.value.selectedPatchCommit.commit.message, - patchCommitUrl: releaseBranchRes.value.selectedPatchCommit.htmlUrl, + patchCommitMessage: selectedPatchCommit.commit.message, + patchCommitUrl: selectedPatchCommit.htmlUrl, patchedTag: updatedReleaseRes.value.tagName, previousTag: latestRelease.tagName, updatedReleaseName: updatedReleaseRes.value.name, updatedReleaseUrl: updatedReleaseRes.value.htmlUrl, }); } catch (error) { - asyncCatcher(error); + asyncCatcher(error as Error); } addStepToResponseSteps({ @@ -361,7 +370,7 @@ ${selectedPatchCommit.commit.message}`, }); }, [updatedReleaseRes.value, updatedReleaseRes.error]); - const TOTAL_STEPS = 9 + (!!onSuccess ? 1 : 0); + const TOTAL_STEPS = 9 + (!!onSuccess ? 1 : 0) + TOTAL_PATCH_PREP_STEPS; const [progress, setProgress] = useState(0); useEffect(() => { setProgress((responseSteps.length / TOTAL_STEPS) * 100); @@ -371,10 +380,6 @@ ${selectedPatchCommit.commit.message}`, progress, responseSteps, run, - runInvoked: Boolean( - releaseBranchRes.loading || - releaseBranchRes.value || - releaseBranchRes.error, - ), + runInvoked, }; } diff --git a/plugins/git-release-manager/src/features/Patch/hooks/usePatchPrep.ts b/plugins/git-release-manager/src/features/Patch/hooks/usePatchPrep.ts new file mode 100644 index 0000000000..a302c3fae8 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatchPrep.ts @@ -0,0 +1,335 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core-plugin-api'; + +import { GetRecentCommitsResultSingle } from '../../../api/GitReleaseClient'; +import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts'; +import { getPatchCommitSuffix } from '../helpers/getPatchCommitSuffix'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { Project } from '../../../contexts/ProjectContext'; +import { SemverTagParts } from '../../../helpers/tagParts/getSemverTagParts'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; + +export interface UsePatchPreparation { + bumpedTag: string; + releaseBranchName: string; + project: Project; + tagParts: NonNullable; +} + +// Inspiration: https://stackoverflow.com/questions/53859199/how-to-cherry-pick-through-githubs-api +export function usePatchPreparation({ + bumpedTag, + releaseBranchName, + project, + tagParts, +}: UsePatchPreparation) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { responseSteps, addStepToResponseSteps, asyncCatcher, abortIfError } = + useResponseSteps(); + + const tempPatchBranchName = `${releaseBranchName}-temp-patch-prep`; + + /** + * (1) Get the release branch's most recent commit + */ + const [latestCommitOnReleaseBranchRes, run] = useAsyncFn( + async (selectedPatchCommit: GetRecentCommitsResultSingle) => { + const { commit: latestCommit } = await pluginApiClient + .getCommit({ + owner: project.owner, + repo: project.repo, + ref: releaseBranchName, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `[patch prep] Fetched latest commit from "${releaseBranchName}"`, + secondaryMessage: `with message "${latestCommit.commit.message}"`, + link: latestCommit.htmlUrl, + }); + + return { + latestCommit, + selectedPatchCommit, + }; + }, + ); + + /** + * (2) Create temporary patch branch based on release branch's most recent sha + * to test if programmatic cherry pick patching is possible + */ + const createTempPatchBranchRes = useAsync(async () => { + abortIfError(latestCommitOnReleaseBranchRes.error); + if (!latestCommitOnReleaseBranchRes.value) return undefined; + + const { reference: createdReleaseBranch } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + sha: latestCommitOnReleaseBranchRes.value.latestCommit.sha, + ref: `refs/heads/${tempPatchBranchName}`, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Branch "${tempPatchBranchName}" already exists: .../tree/${tempPatchBranchName}`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: '[patch prep] Created Temp Patch Branch', + secondaryMessage: `with ref "${createdReleaseBranch.ref}"`, + }); + + return { + ...createdReleaseBranch, + selectedPatchCommit: + latestCommitOnReleaseBranchRes.value.selectedPatchCommit, + }; + }, [ + latestCommitOnReleaseBranchRes.value, + latestCommitOnReleaseBranchRes.error, + ]); + + /** + * (3) 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 tempPatchBranchRes = useAsync(async () => { + abortIfError(createTempPatchBranchRes.error); + if (!createTempPatchBranchRes.value) return undefined; + + const { branch: releaseBranch } = await pluginApiClient + .getBranch({ + owner: project.owner, + repo: project.repo, + branch: tempPatchBranchName, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `[patch prep] Fetched release branch "${releaseBranch.name}"`, + link: releaseBranch.links.html, + }); + + return { + releaseBranch, + selectedPatchCommit: createTempPatchBranchRes.value.selectedPatchCommit, + }; + }, [createTempPatchBranchRes.value, createTempPatchBranchRes.error]); + + /** + * (4) 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(tempPatchBranchRes.error); + if (!tempPatchBranchRes.value) return undefined; + + const { commit: tempCommit } = await pluginApiClient + .createCommit({ + owner: project.owner, + repo: project.repo, + message: `[patch prep] Temporary commit for patch ${tagParts.patch}`, + parents: [ + tempPatchBranchRes.value.selectedPatchCommit.firstParentSha ?? '', + ], + tree: tempPatchBranchRes.value.releaseBranch.commit.commit.tree.sha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: '[patch prep] Created temporary commit', + secondaryMessage: `with message "${tempCommit.message}"`, + }); + + return { + ...tempCommit, + }; + }, [tempPatchBranchRes.value, tempPatchBranchRes.error]); + + /** + * (5) 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/${tempPatchBranchName}`, + force: true, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `[patch prep] Forced branch "${tempPatchBranchName}" to temporary commit "${tempCommitRes.value.sha}"`, + }); + + return { + trigger: 'next step 🚀 ', + }; + }, [tempCommitRes.value, tempCommitRes.error]); + + /** + * (6) 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 || !tempPatchBranchRes.value) return undefined; + + const { merge } = await pluginApiClient + .merge({ + owner: project.owner, + repo: project.repo, + base: tempPatchBranchName, + head: tempPatchBranchRes.value.selectedPatchCommit.sha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `[patch prep] Merged temporary commit into "${tempPatchBranchName}"`, + secondaryMessage: `with message "${merge.commit.message}"`, + link: merge.htmlUrl, + }); + + return { + ...merge, + }; + }, [forceBranchRes.value, forceBranchRes.error]); + + /** + * (7) 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 || !tempPatchBranchRes.value) return undefined; + + const releaseBranchSha = tempPatchBranchRes.value.releaseBranch.commit.sha; + const selectedPatchCommit = tempPatchBranchRes.value.selectedPatchCommit; + + const { commit: cherryPickCommit } = await pluginApiClient.createCommit({ + owner: project.owner, + repo: project.repo, + message: `[patch prep] [patch ${bumpedTag}] ${ + selectedPatchCommit.commit.message + } + + ${getPatchCommitSuffix({ + commitSha: selectedPatchCommit.sha, + })}`, + parents: [releaseBranchSha], + tree: mergeRes.value.commit.tree.sha, + }); + + addStepToResponseSteps({ + message: `[patch prep] Cherry-picked patch commit to "${releaseBranchSha}"`, + secondaryMessage: `with message "${cherryPickCommit.message}"`, + }); + + return { + ...cherryPickCommit, + }; + }, [mergeRes.value, mergeRes.error]); + + /** + * (8) 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/${tempPatchBranchName}`, + sha: cherryPickRes.value.sha, + force: true, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `[patch prep] Updated reference "${updatedReference.ref}"`, + }); + + return { + ...updatedReference, + }; + }, [cherryPickRes.value, cherryPickRes.error]); + + /** + * (9) Delete temp patch branch + */ + const deleteTempPatchBranchRes = useAsync(async () => { + abortIfError(cherryPickRes.error); + if (!cherryPickRes.value) return undefined; + + const { success: deletedReferenceSuccess } = + await pluginApiClient.deleteRef({ + owner: project.owner, + repo: project.repo, + ref: `heads/${tempPatchBranchName}`, + }); + + addStepToResponseSteps({ + message: `[patch prep] Deleted temporary patch prep branch "${tempPatchBranchName}"`, + }); + + return { + deletedReferenceSuccess, + }; + }, [updatedRefRes.value, updatedRefRes.error]); + + const TOTAL_PATCH_PREP_STEPS = 9; + + return { + TOTAL_PATCH_PREP_STEPS, + run, + runInvoked: Boolean( + deleteTempPatchBranchRes.loading || + deleteTempPatchBranchRes.value || + deleteTempPatchBranchRes.error, + ), + lastCallRes: deleteTempPatchBranchRes, + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + selectedPatchCommit: latestCommitOnReleaseBranchRes.value + ?.selectedPatchCommit as any, + }; +}