Browse Source

feat(autofix): Add checkout locally flow (#82960)

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.

<img width="653" alt="Screenshot 2025-01-06 at 10 23 15 AM"
src="https://github.com/user-attachments/assets/b3fdff75-8443-413a-8eb6-61c4811dbfa7"
/>
<img width="650" alt="Screenshot 2025-01-06 at 10 23 22 AM"
src="https://github.com/user-attachments/assets/620b4635-66e4-43ee-90e8-b23d93ec95e3"
/>
<img width="663" alt="Screenshot 2025-01-06 at 11 12 02 AM"
src="https://github.com/user-attachments/assets/6ef014d4-08d7-4cde-a286-e8cc78891e6d"
/>
Rohan Agarwal 2 months ago
parent
commit
b381b2114d

+ 3 - 3
static/app/components/events/autofix/autofixMessageBox.analytics.spec.tsx

@@ -130,7 +130,7 @@ describe('AutofixMessageBox Analytics', () => {
 
 
     render(<AutofixMessageBox {...changesStepProps} />);
     render(<AutofixMessageBox {...changesStepProps} />);
 
 
-    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
     // Find the last call to Button that matches our Create PR button
     const createPRButtonCall = mockButton.mock.calls.find(
     const createPRButtonCall = mockButton.mock.calls.find(
@@ -160,11 +160,11 @@ describe('AutofixMessageBox Analytics', () => {
 
 
     render(<AutofixMessageBox {...changesStepProps} />);
     render(<AutofixMessageBox {...changesStepProps} />);
 
 
-    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
     // Find the last call to Button that matches our Setup button
     const setupButtonCall = mockButton.mock.calls.find(
     const setupButtonCall = mockButton.mock.calls.find(
-      call => call[0].children === 'Create PRs'
+      call => call[0].children === 'Draft PR'
     );
     );
     expect(setupButtonCall?.[0]).toEqual(
     expect(setupButtonCall?.[0]).toEqual(
       expect.objectContaining({
       expect.objectContaining({

+ 15 - 21
static/app/components/events/autofix/autofixMessageBox.spec.tsx

@@ -204,7 +204,7 @@ describe('AutofixMessageBox', () => {
     render(<AutofixMessageBox {...changesStepProps} />);
     render(<AutofixMessageBox {...changesStepProps} />);
 
 
     expect(screen.getByRole('button', {name: 'Iterate'})).toBeInTheDocument();
     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();
     expect(screen.getByRole('button', {name: 'Add tests'})).toBeInTheDocument();
   });
   });
 
 
@@ -219,7 +219,7 @@ describe('AutofixMessageBox', () => {
     expect(screen.getByRole('button', {name: 'Send'})).toBeInTheDocument();
     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({
     MockApiClient.addMockResponse({
       url: '/issues/123/autofix/setup/?check_write_access=true',
       url: '/issues/123/autofix/setup/?check_write_access=true',
       method: 'GET',
       method: 'GET',
@@ -234,15 +234,13 @@ describe('AutofixMessageBox', () => {
 
 
     render(<AutofixMessageBox {...changesStepProps} />);
     render(<AutofixMessageBox {...changesStepProps} />);
 
 
-    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({
     MockApiClient.addMockResponse({
       url: '/issues/123/autofix/setup/?check_write_access=true',
       url: '/issues/123/autofix/setup/?check_write_access=true',
       method: 'GET',
       method: 'GET',
@@ -265,12 +263,10 @@ describe('AutofixMessageBox', () => {
 
 
     render(<AutofixMessageBox {...multipleChangesProps} />);
     render(<AutofixMessageBox {...multipleChangesProps} />);
 
 
-    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', () => {
   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({
     MockApiClient.addMockResponse({
       url: '/issues/123/autofix/setup/?check_write_access=true',
       url: '/issues/123/autofix/setup/?check_write_access=true',
       method: 'GET',
       method: 'GET',
@@ -323,13 +319,11 @@ describe('AutofixMessageBox', () => {
 
 
     render(<AutofixMessageBox {...changesStepProps} />);
     render(<AutofixMessageBox {...changesStepProps} />);
 
 
-    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();
     expect(createPRsButton).toBeInTheDocument();
 
 
     renderGlobalModal();
     renderGlobalModal();
@@ -341,10 +335,10 @@ describe('AutofixMessageBox', () => {
     ).toBeInTheDocument();
     ).toBeInTheDocument();
   });
   });
 
 
-  it('shows segmented control options for changes step', () => {
+  it('shows option buttons for changes step', () => {
     render(<AutofixMessageBox {...changesStepProps} />);
     render(<AutofixMessageBox {...changesStepProps} />);
 
 
-    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: 'Iterate'})).toBeInTheDocument();
     expect(screen.getByRole('button', {name: 'Add tests'})).toBeInTheDocument();
     expect(screen.getByRole('button', {name: 'Add tests'})).toBeInTheDocument();
   });
   });

+ 163 - 15
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 {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import {openModal} from 'sentry/actionCreators/modal';
 import {openModal} from 'sentry/actionCreators/modal';
 import {Button, LinkButton} from 'sentry/components/button';
 import {Button, LinkButton} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
 import AutofixActionSelector from 'sentry/components/events/autofix/autofixActionSelector';
 import AutofixActionSelector from 'sentry/components/events/autofix/autofixActionSelector';
 import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback';
 import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback';
 import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
 import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
@@ -27,6 +28,7 @@ import {
   IconCheckmark,
   IconCheckmark,
   IconChevron,
   IconChevron,
   IconClose,
   IconClose,
+  IconCopy,
   IconFatal,
   IconFatal,
   IconOpen,
   IconOpen,
   IconSad,
   IconSad,
@@ -37,6 +39,7 @@ import {singleLineRenderer} from 'sentry/utils/marked';
 import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
 import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
 import testableTransition from 'sentry/utils/testableTransition';
 import testableTransition from 'sentry/utils/testableTransition';
 import useApi from 'sentry/utils/useApi';
 import useApi from 'sentry/utils/useApi';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
 
 
 function useSendMessage({groupId, runId}: {groupId: string; runId: string}) {
 function useSendMessage({groupId, runId}: {groupId: string; runId: string}) {
   const api = useApi({persistInFlight: true});
   const api = useApi({persistInFlight: true});
@@ -109,7 +112,6 @@ function CreatePRsButton({
           payload: {
           payload: {
             type: 'create_pr',
             type: 'create_pr',
             repo_external_id: change.repo_external_id,
             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"
       analyticsEventKey="autofix.create_pr_clicked"
       analyticsParams={{group_id: groupId}}
       analyticsParams={{group_id: groupId}}
     >
     >
-      Create PR{changes.length > 1 ? 's' : ''}
+      Draft PR{changes.length > 1 ? 's' : ''}
+    </Button>
+  );
+}
+
+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 (
+    <Button
+      onClick={pushToBranch}
+      icon={
+        hasClickedPushToBranch && <ProcessingStatusIndicator size={14} mini hideMessage />
+      }
+      busy={hasClickedPushToBranch}
+      analyticsEventName="Autofix: Push to Branch Clicked"
+      analyticsEventKey="autofix.push_to_branch_clicked"
+      analyticsParams={{group_id: groupId}}
+    >
+      Check Out Locally
     </Button>
     </Button>
   );
   );
 }
 }
@@ -169,7 +229,7 @@ function SetupAndCreatePRsButton({
         analyticsParams={{group_id: groupId}}
         analyticsParams={{group_id: groupId}}
         title={t('Enable write access to create pull requests')}
         title={t('Enable write access to create pull requests')}
       >
       >
-        {t('Create PRs')}
+        {t('Draft PR')}
       </Button>
       </Button>
     );
     );
   }
   }
@@ -177,6 +237,41 @@ function SetupAndCreatePRsButton({
   return <CreatePRsButton changes={changes} groupId={groupId} />;
   return <CreatePRsButton changes={changes} groupId={groupId} />;
 }
 }
 
 
+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 (
+      <Button
+        onClick={() => {
+          openModal(deps => <AutofixSetupWriteAccessModal {...deps} groupId={groupId} />);
+        }}
+        analyticsEventName="Autofix: Create PR Setup Clicked"
+        analyticsEventKey="autofix.create_pr_setup_clicked"
+        analyticsParams={{group_id: groupId}}
+        title={t('Enable write access to create branches')}
+      >
+        {t('Check Out Locally')}
+      </Button>
+    );
+  }
+
+  return <CreateBranchButton changes={changes} groupId={groupId} />;
+}
+
 interface RootCauseAndFeedbackInputAreaProps {
 interface RootCauseAndFeedbackInputAreaProps {
   actionText: string;
   actionText: string;
   changesMode: 'give_feedback' | 'add_tests' | 'create_prs' | null;
   changesMode: 'give_feedback' | 'add_tests' | 'create_prs' | null;
@@ -348,6 +443,11 @@ function AutofixMessageBox({
     step?.status === AutofixStatus.COMPLETED &&
     step?.status === AutofixStatus.COMPLETED &&
     changes.length >= 1 &&
     changes.length >= 1 &&
     changes.every(change => change.pull_request);
     changes.every(change => change.pull_request);
+  const branchesMade =
+    !prsMade &&
+    step?.status === AutofixStatus.COMPLETED &&
+    changes.length >= 1 &&
+    changes.every(change => change.branch_name);
 
 
   const isDisabled =
   const isDisabled =
     step?.status === AutofixStatus.ERROR ||
     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 (
+      <Button
+        key={`${change.repo_external_id}-${Math.random()}`}
+        size="xs"
+        priority="primary"
+        onClick={onClick}
+        aria-label={t('Check out in %s', change.repo_name)}
+        title={t('git fetch --all && git switch %s', change.branch_name)}
+        icon={<IconCopy size="xs" />}
+      >
+        {t('Check out in %s', change.repo_name)}
+      </Button>
+    );
+  }
+
   return (
   return (
     <Container>
     <Container>
       <AnimatedContent animate={{height}} transition={{duration: 0.3, ease: 'easeInOut'}}>
       <AnimatedContent animate={{height}} transition={{duration: 0.3, ease: 'easeInOut'}}>
@@ -466,12 +587,16 @@ function AutofixMessageBox({
                     />
                     />
                   )}
                   )}
                 </AutofixActionSelector>
                 </AutofixActionSelector>
-              ) : isChangesStep && !prsMade ? (
+              ) : isChangesStep && !prsMade && !branchesMade ? (
                 <AutofixActionSelector
                 <AutofixActionSelector
                   options={[
                   options={[
                     {key: 'add_tests', label: t('Add tests')},
                     {key: 'add_tests', label: t('Add tests')},
                     {key: 'give_feedback', label: t('Iterate')},
                     {key: 'give_feedback', label: t('Iterate')},
-                    {key: 'create_prs', label: t('Approve'), active: true},
+                    {
+                      key: 'create_prs',
+                      label: t('Use this code'),
+                      active: true,
+                    },
                   ]}
                   ]}
                   selected={changesMode}
                   selected={changesMode}
                   onSelect={value => setChangesMode(value)}
                   onSelect={value => setChangesMode(value)}
@@ -508,17 +633,29 @@ function AutofixMessageBox({
                       {option.key === 'create_prs' && (
                       {option.key === 'create_prs' && (
                         <InputArea>
                         <InputArea>
                           <StaticMessage>
                           <StaticMessage>
-                            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'}
+                            ?
                           </StaticMessage>
                           </StaticMessage>
-                          <SetupAndCreatePRsButton changes={changes} groupId={groupId} />
+                          <ButtonBar gap={1}>
+                            <SetupAndCreateBranchButton
+                              changes={changes}
+                              groupId={groupId}
+                            />
+                            <SetupAndCreatePRsButton
+                              changes={changes}
+                              groupId={groupId}
+                            />
+                          </ButtonBar>
                         </InputArea>
                         </InputArea>
                       )}
                       )}
                     </Fragment>
                     </Fragment>
                   )}
                   )}
                 </AutofixActionSelector>
                 </AutofixActionSelector>
               ) : isChangesStep && prsMade ? (
               ) : isChangesStep && prsMade ? (
-                <ViewPRButtons aria-label={t('View pull requests')}>
+                <StyledScrollCarousel aria-label={t('View pull requests')}>
                   {changes.map(
                   {changes.map(
                     change =>
                     change =>
                       change.pull_request?.pr_url && (
                       change.pull_request?.pr_url && (
@@ -534,7 +671,19 @@ function AutofixMessageBox({
                         </LinkButton>
                         </LinkButton>
                       )
                       )
                   )}
                   )}
-                </ViewPRButtons>
+                </StyledScrollCarousel>
+              ) : isChangesStep && branchesMade ? (
+                <StyledScrollCarousel aria-label={t('Check out branches')}>
+                  {changes.map(
+                    change =>
+                      change.branch_name && (
+                        <BranchButton
+                          key={`${change.repo_external_id}-${Math.random()}`}
+                          change={change}
+                        />
+                      )
+                  )}
+                </StyledScrollCarousel>
               ) : (
               ) : (
                 <RootCauseAndFeedbackInputArea
                 <RootCauseAndFeedbackInputArea
                   handleSend={handleSend}
                   handleSend={handleSend}
@@ -562,11 +711,6 @@ const Placeholder = styled('div')`
   padding: ${space(1)};
   padding: ${space(1)};
 `;
 `;
 
 
-const ViewPRButtons = styled(ScrollCarousel)`
-  width: 100%;
-  padding: 0 ${space(1)};
-`;
-
 const ScrollIntoViewButtonWrapper = styled('div')`
 const ScrollIntoViewButtonWrapper = styled('div')`
   position: absolute;
   position: absolute;
   top: -2rem;
   top: -2rem;
@@ -587,6 +731,10 @@ const Container = styled('div')`
   flex-direction: column;
   flex-direction: column;
 `;
 `;
 
 
+const StyledScrollCarousel = styled(ScrollCarousel)`
+  padding: 0 ${space(1)};
+`;
+
 const AnimatedContent = styled(motion.div)`
 const AnimatedContent = styled(motion.div)`
   overflow: hidden;
   overflow: hidden;
 `;
 `;

+ 1 - 0
static/app/components/events/autofix/types.ts

@@ -148,6 +148,7 @@ export type AutofixCodebaseChange = {
   diff: FilePatch[];
   diff: FilePatch[];
   repo_name: string;
   repo_name: string;
   title: string;
   title: string;
+  branch_name?: string;
   diff_str?: string;
   diff_str?: string;
   pull_request?: AutofixPullRequestDetails;
   pull_request?: AutofixPullRequestDetails;
   repo_external_id?: string;
   repo_external_id?: string;