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. Screenshot 2025-01-06 at 10 23 15 AM Screenshot 2025-01-06 at 10 23 22 AM Screenshot 2025-01-06 at 11 12 02 AM --- .../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 ( + ); } @@ -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 ( + + ); + } + 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 && ( + + ) + )} + ) : (