Browse Source

feat(autofix): Display installation text when creating a PR without permissions (#72158)

Malachi Willey 9 months ago
parent
commit
9bb91d5e3f

+ 1 - 1
static/app/components/events/autofix/autofixBanner.tsx

@@ -8,10 +8,10 @@ import bannerStars from 'sentry-images/spot/ai-suggestion-banner-stars.svg';
 import {openModal} from 'sentry/actionCreators/modal';
 import {Button} from 'sentry/components/button';
 import {AutofixInstructionsModal} from 'sentry/components/events/autofix/autofixInstructionsModal';
+import {AutofixSetupModal} from 'sentry/components/events/autofix/autofixSetupModal';
 import {AutofixCodebaseIndexingStatus} from 'sentry/components/events/autofix/types';
 import {useAutofixCodebaseIndexing} from 'sentry/components/events/autofix/useAutofixCodebaseIndexing';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {AutofixSetupModal} from 'sentry/components/modals/autofixSetupModal';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import {t} from 'sentry/locale';

+ 92 - 0
static/app/components/events/autofix/autofixChanges.spec.tsx

@@ -0,0 +1,92 @@
+import {AutofixCodebaseChangeData} from 'sentry-fixture/autofixCodebaseChangeData';
+import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
+
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+  within,
+} from 'sentry-test/reactTestingLibrary';
+
+import {AutofixChanges} from 'sentry/components/events/autofix/autofixChanges';
+import {
+  type AutofixChangesStep,
+  AutofixStepType,
+} from 'sentry/components/events/autofix/types';
+
+describe('AutofixChanges', function () {
+  const defaultProps = {
+    groupId: '1',
+    onRetry: jest.fn(),
+    step: AutofixStepFixture({
+      type: AutofixStepType.CHANGES,
+      changes: [AutofixCodebaseChangeData()],
+    }) as AutofixChangesStep,
+  };
+
+  it('displays link to PR when one exists', function () {
+    MockApiClient.addMockResponse({
+      url: '/issues/1/autofix/setup/',
+      body: {
+        genAIConsent: {ok: true},
+        codebaseIndexing: {ok: true},
+        integration: {ok: true},
+        githubWriteIntegration: {
+          repos: [{ok: true, owner: 'owner', name: 'hello-world', id: 100}],
+        },
+      },
+    });
+
+    render(<AutofixChanges {...defaultProps} />);
+
+    expect(
+      screen.queryByRole('button', {name: 'Create a Pull Request'})
+    ).not.toBeInTheDocument();
+
+    expect(screen.getByRole('button', {name: 'View Pull Request'})).toHaveAttribute(
+      'href',
+      'https://github.com/owner/hello-world/pull/200'
+    );
+  });
+
+  it('displays setup button when permissions do not exist for repo', async function () {
+    MockApiClient.addMockResponse({
+      url: '/issues/1/autofix/setup/',
+      body: {
+        genAIConsent: {ok: true},
+        codebaseIndexing: {ok: true},
+        integration: {ok: true},
+        githubWriteIntegration: {
+          repos: [
+            {ok: false, provider: 'github', owner: 'owner', name: 'hello-world', id: 100},
+          ],
+        },
+      },
+    });
+
+    render(
+      <AutofixChanges
+        {...defaultProps}
+        step={
+          AutofixStepFixture({
+            type: AutofixStepType.CHANGES,
+            changes: [
+              AutofixCodebaseChangeData({
+                pull_request: undefined,
+              }),
+            ],
+          }) as AutofixChangesStep
+        }
+      />
+    );
+    renderGlobalModal();
+
+    await userEvent.click(screen.getByRole('button', {name: 'Create a Pull Request'}));
+
+    expect(await screen.findByRole('dialog')).toBeInTheDocument();
+    expect(
+      within(screen.getByRole('dialog')).getByText('Allow Autofix to Make Pull Requests')
+    ).toBeInTheDocument();
+  });
+});

+ 89 - 36
static/app/components/events/autofix/autofixChanges.tsx

@@ -2,8 +2,10 @@ import {Fragment, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import {openModal} from 'sentry/actionCreators/modal';
 import {Button, LinkButton} from 'sentry/components/button';
 import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
+import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
 import type {
   AutofixChangesStep,
   AutofixCodebaseChange,
@@ -13,6 +15,7 @@ import {
   makeAutofixQueryKey,
   useAutofixData,
 } from 'sentry/components/events/autofix/useAutofix';
+import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {IconOpen} from 'sentry/icons';
 import {t} from 'sentry/locale';
@@ -26,7 +29,7 @@ type AutofixChangesProps = {
   step: AutofixChangesStep;
 };
 
-function AutofixRepoChange({
+function CreatePullRequestButton({
   change,
   groupId,
 }: {
@@ -36,7 +39,6 @@ function AutofixRepoChange({
   const autofixData = useAutofixData({groupId});
   const api = useApi();
   const queryClient = useQueryClient();
-
   const [hasClickedCreatePr, setHasClickedCreatePr] = useState(false);
 
   const {mutate: createPr} = useMutation({
@@ -82,6 +84,90 @@ function AutofixRepoChange({
     }
   }, [hasClickedCreatePr, change.pull_request]);
 
+  return (
+    <Button
+      size="xs"
+      onClick={() => {
+        createPr();
+        setHasClickedCreatePr(true);
+      }}
+      icon={
+        hasClickedCreatePr && <ProcessingStatusIndicator size={14} mini hideMessage />
+      }
+      busy={hasClickedCreatePr}
+      analyticsEventName="Autofix: Create PR Clicked"
+      analyticsEventKey="autofix.create_pr_clicked"
+      analyticsParams={{group_id: groupId}}
+    >
+      {t('Create a Pull Request')}
+    </Button>
+  );
+}
+
+function PullRequestLinkOrCreateButton({
+  change,
+  groupId,
+}: {
+  change: AutofixCodebaseChange;
+  groupId: string;
+}) {
+  const {data} = useAutofixSetup({groupId});
+
+  if (change.pull_request) {
+    return (
+      <LinkButton
+        size="xs"
+        icon={<IconOpen size="xs" />}
+        href={change.pull_request.pr_url}
+        external
+        analyticsEventName="Autofix: View PR Clicked"
+        analyticsEventKey="autofix.view_pr_clicked"
+        analyticsParams={{group_id: groupId}}
+      >
+        {t('View Pull Request')}
+      </LinkButton>
+    );
+  }
+
+  if (
+    !data?.githubWriteIntegration?.repos?.find(
+      repo => `${repo.owner}/${repo.name}` === change.repo_name
+    )?.ok
+  ) {
+    return (
+      <Actions>
+        <Button
+          size="xs"
+          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 pull requests')}
+        >
+          {t('Create a Pull Request')}
+        </Button>
+      </Actions>
+    );
+  }
+
+  return (
+    <Actions>
+      <CreatePullRequestButton change={change} groupId={groupId} />
+    </Actions>
+  );
+}
+
+function AutofixRepoChange({
+  change,
+  groupId,
+}: {
+  change: AutofixCodebaseChange;
+  groupId: string;
+}) {
   return (
     <Content>
       <RepoChangesHeader>
@@ -89,40 +175,7 @@ function AutofixRepoChange({
           <Title>{change.repo_name}</Title>
           <PullRequestTitle>{change.title}</PullRequestTitle>
         </div>
-        {!change.pull_request ? (
-          <Actions>
-            <Button
-              size="xs"
-              onClick={() => {
-                createPr();
-                setHasClickedCreatePr(true);
-              }}
-              icon={
-                hasClickedCreatePr && (
-                  <ProcessingStatusIndicator size={14} mini hideMessage />
-                )
-              }
-              busy={hasClickedCreatePr}
-              analyticsEventName="Autofix: Create PR Clicked"
-              analyticsEventKey="autofix.create_pr_clicked"
-              analyticsParams={{group_id: groupId}}
-            >
-              {t('Create a Pull Request')}
-            </Button>
-          </Actions>
-        ) : (
-          <LinkButton
-            size="xs"
-            icon={<IconOpen size="xs" />}
-            href={change.pull_request.pr_url}
-            external
-            analyticsEventName="Autofix: View PR Clicked"
-            analyticsEventKey="autofix.view_pr_clicked"
-            analyticsParams={{group_id: groupId}}
-          >
-            {t('View Pull Request')}
-          </LinkButton>
-        )}
+        <PullRequestLinkOrCreateButton change={change} groupId={groupId} />
       </RepoChangesHeader>
       <AutofixDiff diff={change.diff} />
     </Content>

+ 1 - 1
static/app/components/modals/autofixSetupModal.spec.tsx → static/app/components/events/autofix/autofixSetupModal.spec.tsx

@@ -3,8 +3,8 @@ import {ProjectFixture} from 'sentry-fixture/project';
 import {act, renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 import {openModal} from 'sentry/actionCreators/modal';
+import {AutofixSetupModal} from 'sentry/components/events/autofix/autofixSetupModal';
 import {AutofixCodebaseIndexingStatus} from 'sentry/components/events/autofix/types';
-import {AutofixSetupModal} from 'sentry/components/modals/autofixSetupModal';
 import ProjectsStore from 'sentry/stores/projectsStore';
 
 describe('AutofixSetupModal', function () {

+ 2 - 2
static/app/components/modals/autofixSetupModal.tsx → static/app/components/events/autofix/autofixSetupModal.tsx

@@ -120,7 +120,7 @@ function AutofixIntegrationStep({autofixSetup}: {autofixSetup: AutofixSetupRespo
   );
 }
 
-function GitRepoLink({repo}: {repo: AutofixSetupRepoDefinition}) {
+export function GitRepoLink({repo}: {repo: AutofixSetupRepoDefinition}) {
   if (repo.provider === 'github' || repo.provider.split(':')[1] === 'github') {
     return (
       <RepoLinkItem>
@@ -412,7 +412,7 @@ export function AutofixSetupModal({
   );
 }
 
-const AutofixSetupDone = styled('div')`
+export const AutofixSetupDone = styled('div')`
   position: relative;
   display: flex;
   align-items: center;

+ 139 - 0
static/app/components/events/autofix/autofixSetupWriteAccessModal.tsx

@@ -0,0 +1,139 @@
+import {Fragment, useMemo} from 'react';
+import styled from '@emotion/styled';
+
+import type {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {Button, LinkButton} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {GitRepoLink} from 'sentry/components/events/autofix/autofixSetupModal';
+import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {IconCheckmark} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+interface AutofixSetupWriteAccessModalProps extends ModalRenderProps {
+  groupId: string;
+}
+
+function Content({groupId, closeModal}: {closeModal: () => void; groupId: string}) {
+  const {canCreatePullRequests, data} = useAutofixSetup(
+    {groupId},
+    {refetchOnWindowFocus: true} // We want to check each time the user comes back to the tab
+  );
+
+  const sortedRepos = useMemo(
+    () =>
+      data?.githubWriteIntegration.repos.toSorted((a, b) => {
+        if (a.ok === b.ok) {
+          return `${a.owner}/${a.name}`.localeCompare(`${b.owner}/${b.name}`);
+        }
+        return a.ok ? -1 : 1;
+      }) ?? [],
+    [data]
+  );
+
+  if (canCreatePullRequests) {
+    return (
+      <DoneWrapper>
+        <DoneIcon color="success" size="xxl" isCircled />
+        <p>{t("You've successfully configured write access!")}</p>
+        <Button onClick={closeModal} priority="primary">
+          {t("Let's go")}
+        </Button>
+      </DoneWrapper>
+    );
+  }
+
+  if (sortedRepos.length > 0) {
+    return (
+      <Fragment>
+        <p>
+          {tct(
+            'In order to create pull requests, install and grant write access to the [link:Sentry Autofix Github App] for the following repositories:',
+            {
+              link: (
+                <ExternalLink
+                  href={`https://github.com/apps/sentry-autofix-experimental/installations/new`}
+                />
+              ),
+            }
+          )}
+        </p>
+        <RepoLinkUl>
+          {sortedRepos.map(repo => (
+            <GitRepoLink key={`${repo.owner}/${repo.name}`} repo={repo} />
+          ))}
+        </RepoLinkUl>
+      </Fragment>
+    );
+  }
+
+  return (
+    <Fragment>
+      <p>
+        {tct(
+          'In order to create pull requests, install and grant write access to the [link:Sentry Autofix Github App] for the relevant repositories.',
+          {
+            link: (
+              <ExternalLink
+                href={`https://github.com/apps/sentry-autofix-experimental/installations/new`}
+              />
+            ),
+          }
+        )}
+      </p>
+    </Fragment>
+  );
+}
+
+export function AutofixSetupWriteAccessModal({
+  Header,
+  Body,
+  Footer,
+  groupId,
+  closeModal,
+}: AutofixSetupWriteAccessModalProps) {
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h3>{t('Allow Autofix to Make Pull Requests')}</h3>
+      </Header>
+      <Body>
+        <Content groupId={groupId} closeModal={closeModal} />
+      </Body>
+      <Footer>
+        <ButtonBar gap={1}>
+          <Button onClick={closeModal}>{t('Later')}</Button>
+          <LinkButton
+            href="https://github.com/apps/sentry-autofix-experimental/installations/new"
+            external
+            priority="primary"
+          >
+            {t('Install the Autofix GitHub App')}
+          </LinkButton>
+        </ButtonBar>
+      </Footer>
+    </Fragment>
+  );
+}
+
+const DoneWrapper = styled('div')`
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding: 40px;
+  font-size: ${p => p.theme.fontSizeLarge};
+`;
+
+const DoneIcon = styled(IconCheckmark)`
+  margin-bottom: ${space(4)};
+`;
+
+const RepoLinkUl = styled('ul')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(0.5)};
+  padding: 0;
+`;

+ 17 - 0
tests/js/fixtures/autofixCodebaseChangeData.ts

@@ -0,0 +1,17 @@
+import { AutofixDiffFilePatch } from 'sentry-fixture/autofixDiffFilePatch';
+
+import type { AutofixCodebaseChange } from 'sentry/components/events/autofix/types';
+
+export function AutofixCodebaseChangeData(
+  params: Partial<AutofixCodebaseChange> = {}
+): AutofixCodebaseChange {
+  return {
+    description: '',
+    diff: [AutofixDiffFilePatch()],
+    repo_id: 100,
+    repo_name: 'owner/hello-world',
+    title: 'Add error handling',
+    pull_request: { pr_number: 200, pr_url: 'https://github.com/owner/hello-world/pull/200' },
+    ...params,
+  };
+}