Merge pull request #7473 from backstage/erikengervall/improve-patch-safety
[git-release-manager plugin] Introduce patch dry run
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-git-release-manager': minor
|
||||
---
|
||||
|
||||
Errors caused while patching can leave the release branch in a broken state. Most commonly caused due to merge errors.
|
||||
|
||||
This has been solved by introducing a dry run prior to patching the release branch. The dry run will attempt to cherry pick the selected patch commit onto a temporary branch created off of the release branch. If it succeeds, the temporary branch is deleted and the patch is applied on the release branch
|
||||
@@ -37,6 +37,7 @@ describe('GitReleaseClient', () => {
|
||||
"createRef": [Function],
|
||||
"createRelease": [Function],
|
||||
"createTagObject": [Function],
|
||||
"deleteRef": [Function],
|
||||
"getAllReleases": [Function],
|
||||
"getAllTags": [Function],
|
||||
"getBranch": [Function],
|
||||
|
||||
@@ -293,6 +293,19 @@ export class GitReleaseClient implements GitReleaseApi {
|
||||
};
|
||||
};
|
||||
|
||||
deleteRef: GitReleaseApi['deleteRef'] = async ({ owner, repo, ref }) => {
|
||||
const { octokit } = await this.getOctokit();
|
||||
const createRefResponse = await octokit.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
});
|
||||
|
||||
return {
|
||||
success: createRefResponse.status === 204,
|
||||
};
|
||||
};
|
||||
|
||||
getComparison: GitReleaseApi['getComparison'] = async ({
|
||||
owner,
|
||||
repo,
|
||||
@@ -651,6 +664,14 @@ export interface GitReleaseApi {
|
||||
};
|
||||
}>;
|
||||
|
||||
deleteRef: (
|
||||
args: {
|
||||
ref: string;
|
||||
} & OwnerRepo,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
getComparison: (
|
||||
args: {
|
||||
base: string;
|
||||
|
||||
+1
-1
@@ -279,7 +279,7 @@ export function useCreateReleaseCandidate({
|
||||
previousTag: latestRelease?.tagName,
|
||||
});
|
||||
} catch (error) {
|
||||
asyncCatcher(error);
|
||||
asyncCatcher(error as Error);
|
||||
}
|
||||
|
||||
addStepToResponseSteps({
|
||||
|
||||
@@ -141,6 +141,15 @@ export const PatchBody = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
Patches the release branch, creates a new tag and updates the Git
|
||||
release. A dry run on a temporary branch will run prior to patching
|
||||
the release branch to ensure there's no merge conflicts. Manual
|
||||
patching is recommended should the dry run fail.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={2}>
|
||||
<Typography>
|
||||
<Differ
|
||||
@@ -301,6 +310,7 @@ export const PatchBody = ({
|
||||
gitDataResponse.value?.recentCommitsOnDefaultBranch[
|
||||
checkedCommitIndex
|
||||
];
|
||||
|
||||
if (!selectedPatchCommit) {
|
||||
throw new GitReleaseManagerError(
|
||||
'Could not find selected patch commit',
|
||||
|
||||
@@ -39,7 +39,7 @@ jest.mock('../../../contexts/UserContext', () => ({
|
||||
describe('patch', () => {
|
||||
beforeEach(jest.clearAllMocks);
|
||||
|
||||
it('should return the expected responseSteps and progress', async () => {
|
||||
it('should return the expected responseSteps (including patch dry run) and progress', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePatch({
|
||||
bumpedTag: mockBumpedTag,
|
||||
@@ -54,10 +54,10 @@ describe('patch', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toEqual(undefined);
|
||||
expect(result.current.responseSteps).toHaveLength(9);
|
||||
expect(result.current.responseSteps).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('should return the expected responseSteps and progress (with onSuccess)', async () => {
|
||||
it('should return the expected responseSteps (including patch dry run) and progress (with onSuccess)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePatch({
|
||||
bumpedTag: mockBumpedTag,
|
||||
@@ -73,11 +73,56 @@ describe('patch', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toEqual(undefined);
|
||||
expect(result.current.responseSteps).toHaveLength(10);
|
||||
expect(result.current.responseSteps).toHaveLength(19);
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"progress": 100,
|
||||
"responseSteps": Array [
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Fetched latest commit from \\"rc/2020.01.01_1\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Created temporary patch dry run branch \\"rc/2020.01.01_1-backstage-grm-patch-dry-run\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Fetched release branch \\"rc/1.2.3\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Created temporary commit"
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Forced branch \\"rc/2020.01.01_1-backstage-grm-patch-dry-run\\" to temporary commit \\"mock_commit_sha\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Merged temporary commit into \\"rc/2020.01.01_1-backstage-grm-patch-dry-run\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Cherry-picked patch commit to \\"mock_branch_commit_sha\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Updated reference \\"mock_update_ref_ref\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"message": <PatchDryRunMessage
|
||||
message="Deleted temporary patch prep branch \\"rc/2020.01.01_1-backstage-grm-patch-dry-run\\""
|
||||
/>,
|
||||
},
|
||||
Object {
|
||||
"link": "https://mock_branch_links_html",
|
||||
"message": "Fetched release branch \\"rc/1.2.3\\"",
|
||||
|
||||
@@ -14,27 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useAsync } from 'react-use';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
import {
|
||||
GetLatestReleaseResult,
|
||||
GetRecentCommitsResultSingle,
|
||||
} from '../../../api/GitReleaseClient';
|
||||
|
||||
import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts';
|
||||
import {
|
||||
CardHook,
|
||||
ComponentConfig,
|
||||
PatchOnSuccessArgs,
|
||||
} from '../../../types/types';
|
||||
import {
|
||||
GetLatestReleaseResult,
|
||||
GetRecentCommitsResultSingle,
|
||||
} from '../../../api/GitReleaseClient';
|
||||
import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts';
|
||||
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';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { usePatchDryRun } from './usePatchDryRun';
|
||||
import { useUserContext } from '../../../contexts/UserContext';
|
||||
|
||||
export interface UsePatch {
|
||||
bumpedTag: string;
|
||||
@@ -54,38 +54,53 @@ export function usePatch({
|
||||
}: UsePatch): CardHook<GetRecentCommitsResultSingle> {
|
||||
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,
|
||||
} = usePatchDryRun({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
import React from 'react';
|
||||
|
||||
import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts';
|
||||
import { getPatchCommitSuffix } from '../helpers/getPatchCommitSuffix';
|
||||
import { GetRecentCommitsResultSingle } from '../../../api/GitReleaseClient';
|
||||
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 UsePatchDryRun {
|
||||
bumpedTag: string;
|
||||
releaseBranchName: string;
|
||||
project: Project;
|
||||
tagParts: NonNullable<CalverTagParts | SemverTagParts>;
|
||||
}
|
||||
|
||||
const PatchDryRunMessage = ({ message }: { message: string }) => (
|
||||
<>
|
||||
<strong>[Patch dry run]</strong> {message}
|
||||
</>
|
||||
);
|
||||
|
||||
// Inspiration: https://stackoverflow.com/questions/53859199/how-to-cherry-pick-through-githubs-api
|
||||
export function usePatchDryRun({
|
||||
bumpedTag,
|
||||
releaseBranchName,
|
||||
project,
|
||||
tagParts,
|
||||
}: UsePatchDryRun) {
|
||||
const pluginApiClient = useApi(gitReleaseManagerApiRef);
|
||||
const { responseSteps, addStepToResponseSteps, asyncCatcher, abortIfError } =
|
||||
useResponseSteps();
|
||||
|
||||
const tempPatchBranchName = `${releaseBranchName}-backstage-grm-patch-dry-run`;
|
||||
|
||||
/**
|
||||
* (1) Get the release branch's most recent commit
|
||||
*/
|
||||
const [latestCommitOnReleaseBranchRes, run] = useAsyncFn(
|
||||
async (selectedPatchCommit: GetRecentCommitsResultSingle) => {
|
||||
try {
|
||||
await pluginApiClient.deleteRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `heads/${tempPatchBranchName}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message !== 'Reference does not exist') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const { commit: latestCommit } = await pluginApiClient
|
||||
.getCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: releaseBranchName,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: (
|
||||
<PatchDryRunMessage
|
||||
message={`Fetched latest commit from "${releaseBranchName}"`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
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: (
|
||||
<PatchDryRunMessage
|
||||
message={`Created temporary patch dry run branch "${tempPatchBranchName}"`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
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: (
|
||||
<PatchDryRunMessage
|
||||
message={`Fetched release branch "${releaseBranch.name}"`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
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: `Temporary commit for patch ${tagParts.patch}`,
|
||||
parents: [
|
||||
tempPatchBranchRes.value.selectedPatchCommit.firstParentSha ?? '',
|
||||
],
|
||||
tree: tempPatchBranchRes.value.releaseBranch.commit.commit.tree.sha,
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: <PatchDryRunMessage message="Created temporary commit" />,
|
||||
});
|
||||
|
||||
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: (
|
||||
<PatchDryRunMessage
|
||||
message={`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(async error => {
|
||||
if (error?.message === 'Merge conflict') {
|
||||
try {
|
||||
await pluginApiClient.deleteRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `heads/${tempPatchBranchName}`,
|
||||
});
|
||||
} catch (_error) {
|
||||
// swallow
|
||||
}
|
||||
|
||||
throw new GitReleaseManagerError(
|
||||
'Patching failed due to merge conflict. Will attempt to delete temporary patch dry run branch. Manual patching is recommended.',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
})
|
||||
.catch(asyncCatcher);
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: (
|
||||
<PatchDryRunMessage
|
||||
message={`Merged temporary commit into "${tempPatchBranchName}"`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
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 {
|
||||
commit: { message },
|
||||
sha: commitSha,
|
||||
} = tempPatchBranchRes.value.selectedPatchCommit;
|
||||
|
||||
const { commit: cherryPickCommit } = await pluginApiClient.createCommit({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
message: `[patch (dry run) ${bumpedTag}] ${message}
|
||||
|
||||
${getPatchCommitSuffix({ commitSha })}`,
|
||||
parents: [releaseBranchSha],
|
||||
tree: mergeRes.value.commit.tree.sha,
|
||||
});
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: (
|
||||
<PatchDryRunMessage
|
||||
message={`Cherry-picked patch commit to "${releaseBranchSha}"`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
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: (
|
||||
<PatchDryRunMessage
|
||||
message={`Updated reference "${updatedReference.ref}"`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedReference,
|
||||
};
|
||||
}, [cherryPickRes.value, cherryPickRes.error]);
|
||||
|
||||
/**
|
||||
* (9) Delete temp patch branch
|
||||
*/
|
||||
const deleteTempPatchBranchRes = useAsync(async () => {
|
||||
abortIfError(updatedRefRes.error);
|
||||
if (!updatedRefRes.value) return undefined;
|
||||
|
||||
const { success: deletedReferenceSuccess } =
|
||||
await pluginApiClient.deleteRef({
|
||||
owner: project.owner,
|
||||
repo: project.repo,
|
||||
ref: `heads/${tempPatchBranchName}`,
|
||||
});
|
||||
|
||||
addStepToResponseSteps({
|
||||
message: (
|
||||
<PatchDryRunMessage
|
||||
message={`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,
|
||||
};
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export function usePromoteRc({
|
||||
updatedTagUrl: promotedReleaseRes.value.htmlUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
asyncCatcher(error);
|
||||
asyncCatcher(error as Error);
|
||||
}
|
||||
|
||||
addStepToResponseSteps({
|
||||
|
||||
@@ -72,7 +72,16 @@ describe('useResponseSteps', () => {
|
||||
Array [
|
||||
Object {
|
||||
"icon": "failure",
|
||||
"message": "Something went wrong 🔥",
|
||||
"message": <b>
|
||||
Something went wrong
|
||||
|
||||
<span
|
||||
aria-label="fire"
|
||||
role="img"
|
||||
>
|
||||
🔥
|
||||
</span>
|
||||
</b>,
|
||||
"secondaryMessage": "Error message: :(",
|
||||
},
|
||||
]
|
||||
@@ -96,7 +105,16 @@ describe('useResponseSteps', () => {
|
||||
Array [
|
||||
Object {
|
||||
"icon": "failure",
|
||||
"message": "Something went wrong 🔥",
|
||||
"message": <b>
|
||||
Something went wrong
|
||||
|
||||
<span
|
||||
aria-label="fire"
|
||||
role="img"
|
||||
>
|
||||
🔥
|
||||
</span>
|
||||
</b>,
|
||||
"secondaryMessage": "Error message: unknown",
|
||||
},
|
||||
]
|
||||
@@ -105,29 +123,6 @@ describe('useResponseSteps', () => {
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
|
||||
+9
-8
@@ -14,15 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import React, { 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<ResponseStep[]>([]);
|
||||
|
||||
@@ -32,7 +27,14 @@ export function useResponseSteps() {
|
||||
|
||||
const asyncCatcher = (error: Error): never => {
|
||||
const responseStepError: ResponseStep = {
|
||||
message: 'Something went wrong 🔥',
|
||||
message: (
|
||||
<b>
|
||||
Something went wrong{' '}
|
||||
<span role="img" aria-label="fire">
|
||||
🔥
|
||||
</span>
|
||||
</b>
|
||||
),
|
||||
secondaryMessage: `Error message: ${
|
||||
error?.message ? error.message : 'unknown'
|
||||
}`,
|
||||
@@ -45,7 +47,6 @@ export function useResponseSteps() {
|
||||
|
||||
const abortIfError = (error?: Error) => {
|
||||
if (error) {
|
||||
addStepToResponseSteps(RESPONSE_STEP_FAILURE_ABORT);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -87,6 +87,10 @@ export const mockApiClient: GitReleaseApi = {
|
||||
},
|
||||
})),
|
||||
|
||||
deleteRef: jest.fn(async () => ({
|
||||
success: true,
|
||||
})),
|
||||
|
||||
createRelease: jest.fn(async () => ({
|
||||
release: {
|
||||
name: 'mock_createRelease_name',
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface PatchOnSuccessArgs {
|
||||
}
|
||||
|
||||
export interface ResponseStep {
|
||||
message: string | React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
secondaryMessage?: string | React.ReactNode;
|
||||
link?: string;
|
||||
icon?: 'success' | 'failure';
|
||||
|
||||
Reference in New Issue
Block a user