From b381b2114d4d3e2990a7e5c16a43dbba5ec4b5fe Mon Sep 17 00:00:00 2001
From: Rohan Agarwal <47861399+roaga@users.noreply.github.com>
Date: Wed, 8 Jan 2025 16:59:53 -0500
Subject: [PATCH] feat(autofix): Add checkout locally flow (#82960)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Give option to "checkout locally", which creates branches in the correct
repos and then displays buttons to copy a `git switch` command to
checkout each branch.
---
.../autofixMessageBox.analytics.spec.tsx | 6 +-
.../events/autofix/autofixMessageBox.spec.tsx | 36 ++--
.../events/autofix/autofixMessageBox.tsx | 178 ++++++++++++++++--
static/app/components/events/autofix/types.ts | 1 +
4 files changed, 182 insertions(+), 39 deletions(-)
diff --git a/static/app/components/events/autofix/autofixMessageBox.analytics.spec.tsx b/static/app/components/events/autofix/autofixMessageBox.analytics.spec.tsx
index 90c0902494c002..31834a9e7239c9 100644
--- a/static/app/components/events/autofix/autofixMessageBox.analytics.spec.tsx
+++ b/static/app/components/events/autofix/autofixMessageBox.analytics.spec.tsx
@@ -130,7 +130,7 @@ describe('AutofixMessageBox Analytics', () => {
render();
- await userEvent.click(screen.getByRole('button', {name: 'Approve'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Use this code'}));
// Find the last call to Button that matches our Create PR button
const createPRButtonCall = mockButton.mock.calls.find(
@@ -160,11 +160,11 @@ describe('AutofixMessageBox Analytics', () => {
render();
- await userEvent.click(screen.getByRole('button', {name: 'Approve'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Use this code'}));
// Find the last call to Button that matches our Setup button
const setupButtonCall = mockButton.mock.calls.find(
- call => call[0].children === 'Create PRs'
+ call => call[0].children === 'Draft PR'
);
expect(setupButtonCall?.[0]).toEqual(
expect.objectContaining({
diff --git a/static/app/components/events/autofix/autofixMessageBox.spec.tsx b/static/app/components/events/autofix/autofixMessageBox.spec.tsx
index e0a249c45316af..d69f4d7c364f7c 100644
--- a/static/app/components/events/autofix/autofixMessageBox.spec.tsx
+++ b/static/app/components/events/autofix/autofixMessageBox.spec.tsx
@@ -204,7 +204,7 @@ describe('AutofixMessageBox', () => {
render();
expect(screen.getByRole('button', {name: 'Iterate'})).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Approve'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Use this code'})).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Add tests'})).toBeInTheDocument();
});
@@ -219,7 +219,7 @@ describe('AutofixMessageBox', () => {
expect(screen.getByRole('button', {name: 'Send'})).toBeInTheDocument();
});
- it('shows "Create PR" button when "Approve" is selected', async () => {
+ it('shows "Draft PR" button when "Approve" is selected', async () => {
MockApiClient.addMockResponse({
url: '/issues/123/autofix/setup/?check_write_access=true',
method: 'GET',
@@ -234,15 +234,13 @@ describe('AutofixMessageBox', () => {
render();
- await userEvent.click(screen.getByRole('button', {name: 'Approve'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Use this code'}));
- expect(
- screen.getByText('Draft 1 pull request for the above changes?')
- ).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Create PR'})).toBeInTheDocument();
+ expect(screen.getByText('Push the above changes to a branch?')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Draft PR'})).toBeInTheDocument();
});
- it('shows "Create PRs" button with correct text for multiple changes', async () => {
+ it('shows "Draft PRs" button with correct text for multiple changes', async () => {
MockApiClient.addMockResponse({
url: '/issues/123/autofix/setup/?check_write_access=true',
method: 'GET',
@@ -265,12 +263,10 @@ describe('AutofixMessageBox', () => {
render();
- await userEvent.click(screen.getByRole('button', {name: 'Approve'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Use this code'}));
- expect(
- screen.getByText('Draft 2 pull requests for the above changes?')
- ).toBeInTheDocument();
- expect(screen.getByRole('button', {name: 'Create PRs'})).toBeInTheDocument();
+ expect(screen.getByText('Push the above changes to 2 branches?')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Draft PRs'})).toBeInTheDocument();
});
it('shows "View PR" buttons when PRs are created', () => {
@@ -298,7 +294,7 @@ describe('AutofixMessageBox', () => {
);
});
- it('shows "Create PRs" button that opens setup modal when setup is incomplete', async () => {
+ it('shows "Draft PRs" button that opens setup modal when setup is incomplete', async () => {
MockApiClient.addMockResponse({
url: '/issues/123/autofix/setup/?check_write_access=true',
method: 'GET',
@@ -323,13 +319,11 @@ describe('AutofixMessageBox', () => {
render();
- await userEvent.click(screen.getByRole('button', {name: 'Approve'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Use this code'}));
- expect(
- screen.getByText('Draft 1 pull request for the above changes?')
- ).toBeInTheDocument();
+ expect(screen.getByText('Push the above changes to a branch?')).toBeInTheDocument();
- const createPRsButton = screen.getByRole('button', {name: 'Create PRs'});
+ const createPRsButton = screen.getByRole('button', {name: 'Draft PR'});
expect(createPRsButton).toBeInTheDocument();
renderGlobalModal();
@@ -341,10 +335,10 @@ describe('AutofixMessageBox', () => {
).toBeInTheDocument();
});
- it('shows segmented control options for changes step', () => {
+ it('shows option buttons for changes step', () => {
render();
- expect(screen.getByRole('button', {name: 'Approve'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Use this code'})).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Iterate'})).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Add tests'})).toBeInTheDocument();
});
diff --git a/static/app/components/events/autofix/autofixMessageBox.tsx b/static/app/components/events/autofix/autofixMessageBox.tsx
index fe8106687d5ba8..885d9837ce3193 100644
--- a/static/app/components/events/autofix/autofixMessageBox.tsx
+++ b/static/app/components/events/autofix/autofixMessageBox.tsx
@@ -5,6 +5,7 @@ import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {openModal} from 'sentry/actionCreators/modal';
import {Button, LinkButton} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
import AutofixActionSelector from 'sentry/components/events/autofix/autofixActionSelector';
import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback';
import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
@@ -27,6 +28,7 @@ import {
IconCheckmark,
IconChevron,
IconClose,
+ IconCopy,
IconFatal,
IconOpen,
IconSad,
@@ -37,6 +39,7 @@ import {singleLineRenderer} from 'sentry/utils/marked';
import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
import testableTransition from 'sentry/utils/testableTransition';
import useApi from 'sentry/utils/useApi';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
function useSendMessage({groupId, runId}: {groupId: string; runId: string}) {
const api = useApi({persistInFlight: true});
@@ -109,7 +112,6 @@ function CreatePRsButton({
payload: {
type: 'create_pr',
repo_external_id: change.repo_external_id,
- repo_id: change.repo_id, // The repo_id is only here for temporary backwards compatibility for LA customers, and we should remove it soon.
},
},
});
@@ -136,7 +138,65 @@ function CreatePRsButton({
analyticsEventKey="autofix.create_pr_clicked"
analyticsParams={{group_id: groupId}}
>
- Create PR{changes.length > 1 ? 's' : ''}
+ Draft PR{changes.length > 1 ? 's' : ''}
+
+ );
+}
+
+function CreateBranchButton({
+ changes,
+ groupId,
+}: {
+ changes: AutofixCodebaseChange[];
+ groupId: string;
+}) {
+ const autofixData = useAutofixData({groupId});
+ const api = useApi();
+ const queryClient = useQueryClient();
+ const [hasClickedPushToBranch, setHasClickedPushToBranch] = useState(false);
+
+ const pushToBranch = () => {
+ setHasClickedPushToBranch(true);
+ for (const change of changes) {
+ createBranch({change});
+ }
+ };
+
+ const {mutate: createBranch} = useMutation({
+ mutationFn: ({change}: {change: AutofixCodebaseChange}) => {
+ return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
+ method: 'POST',
+ data: {
+ run_id: autofixData?.run_id,
+ payload: {
+ type: 'create_branch',
+ repo_external_id: change.repo_external_id,
+ },
+ },
+ });
+ },
+ onSuccess: () => {
+ addSuccessMessage(t('Pushed to branches.'));
+ queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)});
+ },
+ onError: () => {
+ setHasClickedPushToBranch(false);
+ addErrorMessage(t('Failed to push to branches.'));
+ },
+ });
+
+ return (
+
+ }
+ busy={hasClickedPushToBranch}
+ analyticsEventName="Autofix: Push to Branch Clicked"
+ analyticsEventKey="autofix.push_to_branch_clicked"
+ analyticsParams={{group_id: groupId}}
+ >
+ Check Out Locally
);
}
@@ -169,7 +229,7 @@ function SetupAndCreatePRsButton({
analyticsParams={{group_id: groupId}}
title={t('Enable write access to create pull requests')}
>
- {t('Create PRs')}
+ {t('Draft PR')}
);
}
@@ -177,6 +237,41 @@ function SetupAndCreatePRsButton({
return ;
}
+function SetupAndCreateBranchButton({
+ changes,
+ groupId,
+}: {
+ changes: AutofixCodebaseChange[];
+ groupId: string;
+}) {
+ const {data: setupData} = useAutofixSetup({groupId, checkWriteAccess: true});
+
+ if (
+ !changes.every(
+ change =>
+ setupData?.githubWriteIntegration?.repos?.find(
+ repo => `${repo.owner}/${repo.name}` === change.repo_name
+ )?.ok
+ )
+ ) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
interface RootCauseAndFeedbackInputAreaProps {
actionText: string;
changesMode: 'give_feedback' | 'add_tests' | 'create_prs' | null;
@@ -348,6 +443,11 @@ function AutofixMessageBox({
step?.status === AutofixStatus.COMPLETED &&
changes.length >= 1 &&
changes.every(change => change.pull_request);
+ const branchesMade =
+ !prsMade &&
+ step?.status === AutofixStatus.COMPLETED &&
+ changes.length >= 1 &&
+ changes.every(change => change.branch_name);
const isDisabled =
step?.status === AutofixStatus.ERROR ||
@@ -392,6 +492,27 @@ function AutofixMessageBox({
}
};
+ function BranchButton({change}: {change: AutofixCodebaseChange}) {
+ const {onClick} = useCopyToClipboard({
+ text: `git fetch --all && git switch ${change.branch_name}`,
+ successMessage: t('Command copied. Next stop: your terminal.'),
+ });
+
+ return (
+ }
+ >
+ {t('Check out in %s', change.repo_name)}
+
+ );
+ }
+
return (
@@ -466,12 +587,16 @@ function AutofixMessageBox({
/>
)}
- ) : isChangesStep && !prsMade ? (
+ ) : isChangesStep && !prsMade && !branchesMade ? (
setChangesMode(value)}
@@ -508,17 +633,29 @@ function AutofixMessageBox({
{option.key === 'create_prs' && (
- Draft {changes.length} pull request
- {changes.length > 1 ? 's' : ''} for the above changes?
+ Push the above changes to{' '}
+ {changes.length > 1
+ ? `${changes.length} branches`
+ : 'a branch'}
+ ?
-
+
+
+
+
)}
)}
) : isChangesStep && prsMade ? (
-
+
{changes.map(
change =>
change.pull_request?.pr_url && (
@@ -534,7 +671,19 @@ function AutofixMessageBox({
)
)}
-
+
+ ) : isChangesStep && branchesMade ? (
+
+ {changes.map(
+ change =>
+ change.branch_name && (
+
+ )
+ )}
+
) : (