Merge pull request #7473 from backstage/erikengervall/improve-patch-safety

[git-release-manager plugin] Introduce patch dry run
This commit is contained in:
Erik Engervall
2021-10-07 13:25:13 +02:00
committed by GitHub
13 changed files with 571 additions and 86 deletions
@@ -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;
@@ -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());
@@ -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';