Просмотр исходного кода

feat(autofix): Add root cause UI (#68389)

Malachi Willey 10 месяцев назад
Родитель
Сommit
20c520da64

+ 1 - 0
fixtures/js-stubs/autofixData.tsx

@@ -2,6 +2,7 @@ import {AutofixData} from 'sentry/components/events/autofix/types';
 
 export function AutofixDataFixture(params: Partial<AutofixData>): AutofixData {
   return {
+    run_id: '1',
     status: 'PROCESSING',
     completed_at: '',
     created_at: '',

+ 0 - 15
fixtures/js-stubs/autofixResult.tsx

@@ -1,15 +0,0 @@
-import {AutofixDiffFilePatch} from 'sentry-fixture/autofixDiffFilePatch';
-
-import {AutofixResult} from 'sentry/components/events/autofix/types';
-
-export function AutofixResultFixture(params: Partial<AutofixResult> = {}): AutofixResult {
-  return {
-    title: 'Fixed the bug!',
-    pr_number: 123,
-    description: 'This is a description',
-    pr_url: 'https://github.com/pulls/1234',
-    repo_name: 'getsentry/sentry',
-    diff: [AutofixDiffFilePatch()],
-    ...params,
-  };
-}

+ 7 - 2
fixtures/js-stubs/autofixStep.tsx

@@ -1,12 +1,17 @@
-import {AutofixStep} from 'sentry/components/events/autofix/types';
+import {
+  AutofixDefaultStep,
+  AutofixStep,
+  AutofixStepType,
+} from 'sentry/components/events/autofix/types';
 
 export function AutofixStepFixture(params: Partial<AutofixStep>): AutofixStep {
   return {
+    type: AutofixStepType.DEFAULT,
     id: '1',
     index: 1,
     title: 'I am processing',
     status: 'PROCESSING',
     progress: [],
     ...params,
-  };
+  } as AutofixDefaultStep;
 }

+ 8 - 4
static/app/components/events/autofix/autofixCard.tsx

@@ -2,13 +2,18 @@ import styled from '@emotion/styled';
 
 import {Button} from 'sentry/components/button';
 import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
-import {AutofixResult} from 'sentry/components/events/autofix/fixResult';
 import type {AutofixData} from 'sentry/components/events/autofix/types';
 import Panel from 'sentry/components/panels/panel';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 
-export function AutofixCard({data, onRetry}: {data: AutofixData; onRetry: () => void}) {
+type AutofixCardProps = {
+  data: AutofixData;
+  groupId: string;
+  onRetry: () => void;
+};
+
+export function AutofixCard({data, onRetry, groupId}: AutofixCardProps) {
   return (
     <AutofixPanel>
       <AutofixHeader>
@@ -17,8 +22,7 @@ export function AutofixCard({data, onRetry}: {data: AutofixData; onRetry: () =>
           Start Over
         </Button>
       </AutofixHeader>
-      <AutofixSteps data={data} />
-      <AutofixResult autofixData={data} onRetry={onRetry} />
+      <AutofixSteps data={data} runId={data.run_id} groupId={groupId} onRetry={onRetry} />
     </AutofixPanel>
   );
 }

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

@@ -0,0 +1,197 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import {Button, LinkButton} from 'sentry/components/button';
+import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
+import type {
+  AutofixChangesStep,
+  AutofixCodebaseChange,
+} from 'sentry/components/events/autofix/types';
+import {
+  type AutofixResponse,
+  makeAutofixQueryKey,
+  useAutofixData,
+} from 'sentry/components/events/autofix/useAutofix';
+import {IconOpen} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+
+type AutofixChangesProps = {
+  groupId: string;
+  onRetry: () => void;
+  step: AutofixChangesStep;
+};
+
+function AutofixRepoChange({
+  change,
+  groupId,
+}: {
+  change: AutofixCodebaseChange;
+  groupId: string;
+}) {
+  const autofixData = useAutofixData({groupId});
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const {mutate: createPr, isLoading} = useMutation({
+    mutationFn: () => {
+      return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
+        method: 'POST',
+        data: {
+          run_id: autofixData?.run_id,
+          payload: {
+            type: 'create_pr',
+            repo_id: change.repo_id,
+          },
+        },
+      });
+    },
+    onSuccess: () => {
+      setApiQueryData<AutofixResponse>(
+        queryClient,
+        makeAutofixQueryKey(groupId),
+        data => {
+          if (!data || !data.autofix) {
+            return data;
+          }
+
+          return {
+            ...data,
+            autofix: {
+              ...data.autofix,
+              status: 'PROCESSING',
+            },
+          };
+        }
+      );
+    },
+    onError: () => {
+      addErrorMessage(t('Failed to create a pull request'));
+    },
+  });
+
+  return (
+    <Content>
+      <RepoChangesHeader>
+        <div>
+          <Title>{change.repo_name}</Title>
+          <PullRequestTitle>{change.title}</PullRequestTitle>
+        </div>
+        {!change.pull_request ? (
+          <Actions>
+            <Button size="xs" onClick={() => createPr()} busy={isLoading}>
+              {t('Create a Pull Request')}
+            </Button>
+          </Actions>
+        ) : (
+          <LinkButton
+            size="xs"
+            icon={<IconOpen size="xs" />}
+            href={change.pull_request.pr_url}
+            external
+          >
+            {t('View Pull Request')}
+          </LinkButton>
+        )}
+      </RepoChangesHeader>
+      <AutofixDiff diff={change.diff} />
+    </Content>
+  );
+}
+
+export function AutofixChanges({step, onRetry, groupId}: AutofixChangesProps) {
+  const data = useAutofixData({groupId});
+
+  if (step.status === 'ERROR' || data?.status === 'ERROR') {
+    return (
+      <Content>
+        <PreviewContent>
+          {data?.error_message ? (
+            <Fragment>
+              <PrefixText>{t('Something went wrong')}</PrefixText>
+              <span>{data.error_message}</span>
+            </Fragment>
+          ) : (
+            <span>{t('Something went wrong.')}</span>
+          )}
+        </PreviewContent>
+        <Actions>
+          <Button size="xs" onClick={onRetry}>
+            {t('Try Again')}
+          </Button>
+        </Actions>
+      </Content>
+    );
+  }
+
+  if (!step.changes.length) {
+    return (
+      <Content>
+        <PreviewContent>
+          <span>{t('Could not find a fix.')}</span>
+        </PreviewContent>
+        <Actions>
+          <Button size="xs" onClick={onRetry}>
+            {t('Try Again')}
+          </Button>
+        </Actions>
+      </Content>
+    );
+  }
+
+  return (
+    <Content>
+      {step.changes.map((change, i) => (
+        <Fragment key={change.repo_id}>
+          {i > 0 && <Separator />}
+          <AutofixRepoChange change={change} groupId={groupId} />
+        </Fragment>
+      ))}
+    </Content>
+  );
+}
+
+const PreviewContent = styled('div')`
+  display: flex;
+  flex-direction: column;
+  color: ${p => p.theme.textColor};
+  margin-top: ${space(2)};
+`;
+
+const PrefixText = styled('span')``;
+
+const Content = styled('div')`
+  padding: 0 ${space(1)} ${space(1)} ${space(1)};
+`;
+
+const Title = styled('div')`
+  font-weight: bold;
+  margin-bottom: ${space(0.5)};
+`;
+
+const PullRequestTitle = styled('div')`
+  color: ${p => p.theme.subText};
+`;
+
+const RepoChangesHeader = styled('div')`
+  padding: ${space(2)} 0;
+  display: grid;
+  align-items: center;
+  grid-template-columns: 1fr auto;
+`;
+
+const Actions = styled('div')`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  margin-top: ${space(1)};
+`;
+
+const Separator = styled('hr')`
+  border: none;
+  border-top: 1px solid ${p => p.theme.innerBorder};
+  margin: ${space(2)} -${space(2)} 0 -${space(2)};
+`;

+ 482 - 0
static/app/components/events/autofix/autofixRootCause.tsx

@@ -0,0 +1,482 @@
+import {type ReactNode, useState} from 'react';
+import styled from '@emotion/styled';
+import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
+
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {AutofixShowMore} from 'sentry/components/events/autofix/autofixShowMore';
+import {
+  type AutofixRootCauseData,
+  type AutofixRootCauseSelection,
+  type AutofixRootCauseSuggestedFix,
+  type AutofixRootCauseSuggestedFixSnippet,
+  AutofixStepType,
+} from 'sentry/components/events/autofix/types';
+import {
+  type AutofixResponse,
+  makeAutofixQueryKey,
+} from 'sentry/components/events/autofix/useAutofix';
+import TextArea from 'sentry/components/forms/controls/textarea';
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {IconChevron} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {getFileExtension} from 'sentry/utils/fileExtension';
+import {getPrismLanguage} from 'sentry/utils/prism';
+import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+
+type AutofixRootCauseProps = {
+  causes: AutofixRootCauseData[];
+  groupId: string;
+  rootCauseSelection: AutofixRootCauseSelection;
+  runId: string;
+};
+
+const animationProps: AnimationProps = {
+  exit: {opacity: 0},
+  initial: {opacity: 0},
+  animate: {opacity: 1},
+  transition: {duration: 0.3},
+};
+
+function useSelectCause({groupId, runId}: {groupId: string; runId: string}) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (
+      params:
+        | {
+            causeId: string;
+            fixId: string;
+          }
+        | {
+            customRootCause: string;
+          }
+    ) => {
+      return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
+        method: 'POST',
+        data:
+          'customRootCause' in params
+            ? {
+                run_id: runId,
+                payload: {
+                  type: 'select_root_cause',
+                  custom_root_cause: params.customRootCause,
+                },
+              }
+            : {
+                run_id: runId,
+                payload: {
+                  type: 'select_root_cause',
+                  cause_id: params.causeId,
+                  fix_id: params.fixId,
+                },
+              },
+      });
+    },
+    onSuccess: (_, params) => {
+      setApiQueryData<AutofixResponse>(
+        queryClient,
+        makeAutofixQueryKey(groupId),
+        data => {
+          if (!data || !data.autofix) {
+            return data;
+          }
+
+          return {
+            ...data,
+            autofix: {
+              ...data.autofix,
+              status: 'PROCESSING',
+              steps: data.autofix.steps?.map(step => {
+                if (step.type !== AutofixStepType.ROOT_CAUSE_ANALYSIS) {
+                  return step;
+                }
+
+                return {
+                  ...step,
+                  selection:
+                    'customRootCause' in params
+                      ? {
+                          custom_root_cause: params.customRootCause,
+                        }
+                      : {
+                          cause_id: params.causeId,
+                          fix_id: params.fixId,
+                        },
+                };
+              }),
+            },
+          };
+        }
+      );
+    },
+    onError: () => {
+      addErrorMessage(t('Something went wrong when selecting the root cause.'));
+    },
+  });
+}
+
+function RootCauseContent({
+  selected,
+  children,
+}: {
+  children: ReactNode;
+  selected: boolean;
+}) {
+  return (
+    <ContentWrapper selected={selected}>
+      <AnimatePresence initial={false}>
+        {selected && (
+          <AnimationWrapper key="content" {...animationProps}>
+            {children}
+          </AnimationWrapper>
+        )}
+      </AnimatePresence>
+    </ContentWrapper>
+  );
+}
+
+function SuggestedFixSnippet({snippet}: {snippet: AutofixRootCauseSuggestedFixSnippet}) {
+  const extension = getFileExtension(snippet.file_path);
+  const lanugage = extension ? getPrismLanguage(extension) : undefined;
+
+  return (
+    <div>
+      <StyledCodeSnippet filename={snippet.file_path} language={lanugage}>
+        {snippet.snippet}
+      </StyledCodeSnippet>
+    </div>
+  );
+}
+
+function CauseSuggestedFix({
+  fixNumber,
+  suggestedFix,
+  groupId,
+  runId,
+  causeId,
+}: {
+  causeId: string;
+  fixNumber: number;
+  groupId: string;
+  runId: string;
+  suggestedFix: AutofixRootCauseSuggestedFix;
+}) {
+  const {isLoading, mutate: handleSelectFix} = useSelectCause({groupId, runId});
+
+  return (
+    <SuggestedFixWrapper>
+      <SuggestedFixHeader>
+        <strong>{t('Suggested Fix #%s: %s', fixNumber, suggestedFix.title)}</strong>
+        <Button
+          size="xs"
+          onClick={() => handleSelectFix({causeId, fixId: suggestedFix.id})}
+          busy={isLoading}
+        >
+          {t('Continue With This Fix')}
+        </Button>
+      </SuggestedFixHeader>
+      <p>{suggestedFix.description}</p>
+      {suggestedFix.snippet && <SuggestedFixSnippet snippet={suggestedFix.snippet} />}
+    </SuggestedFixWrapper>
+  );
+}
+
+function CauseOption({
+  cause,
+  selected,
+  setSelectedId,
+  runId,
+  groupId,
+}: {
+  cause: AutofixRootCauseData;
+  groupId: string;
+  runId: string;
+  selected: boolean;
+  setSelectedId: (id: string) => void;
+}) {
+  return (
+    <RootCauseOption selected={selected} onClick={() => setSelectedId(cause.id)}>
+      {!selected && <InteractionStateLayer />}
+      <RootCauseOptionHeader>
+        <Title>{cause.title}</Title>
+        <Button
+          icon={<IconChevron size="xs" direction={selected ? 'down' : 'right'} />}
+          aria-label={t('Select root cause')}
+          aria-expanded={selected}
+          size="zero"
+          borderless
+        />
+      </RootCauseOptionHeader>
+      <RootCauseContent selected={selected}>
+        <CauseDescription>{cause.description}</CauseDescription>
+        {cause.suggested_fixes?.map((fix, index) => (
+          <CauseSuggestedFix
+            causeId={cause.id}
+            key={fix.title}
+            suggestedFix={fix}
+            fixNumber={index + 1}
+            groupId={groupId}
+            runId={runId}
+          />
+        )) ?? null}
+      </RootCauseContent>
+    </RootCauseOption>
+  );
+}
+
+function SelectedRootCauseOption({
+  selectedCause,
+  selectedFix,
+}: {
+  selectedCause: AutofixRootCauseData;
+  selectedFix: AutofixRootCauseSuggestedFix;
+}) {
+  return (
+    <RootCauseOption selected>
+      <Title>{t('Selected Cause: %s', selectedCause.title)}</Title>
+      <CauseDescription>{selectedCause.description}</CauseDescription>
+      <SuggestedFixWrapper>
+        <SuggestedFixHeader>
+          <strong>{t('Selected Fix: %s', selectedFix.title)}</strong>
+        </SuggestedFixHeader>
+        <p>{selectedFix.description}</p>
+        {selectedFix.snippet && <SuggestedFixSnippet snippet={selectedFix.snippet} />}
+      </SuggestedFixWrapper>
+    </RootCauseOption>
+  );
+}
+
+function ProvideYourOwn({
+  selected,
+  setSelectedId,
+}: {
+  selected: boolean;
+  setSelectedId: (id: string) => void;
+}) {
+  const [text, setText] = useState('');
+
+  return (
+    <RootCauseOption selected={selected} onClick={() => setSelectedId('custom')}>
+      {!selected && <InteractionStateLayer />}
+      <RootCauseOptionHeader>
+        <Title>{t('Provide your own')}</Title>
+        <Button
+          icon={<IconChevron size="xs" direction={selected ? 'down' : 'right'} />}
+          aria-label={t('Provide your own root cause')}
+          aria-expanded={selected}
+          size="zero"
+          borderless
+        />
+      </RootCauseOptionHeader>
+      <RootCauseContent selected={selected}>
+        <CustomTextArea
+          value={text}
+          onChange={e => setText(e.target.value)}
+          autoFocus
+          autosize
+          placeholder={t(
+            'This error seems to be caused by ... go look at path/file to make sure it does …'
+          )}
+        />
+        <OptionFooter>
+          <Button size="xs" disabled={!text}>
+            {t('Continue With This Fix')}
+          </Button>
+        </OptionFooter>
+      </RootCauseContent>
+    </RootCauseOption>
+  );
+}
+
+export function AutofixRootCause({
+  causes,
+  groupId,
+  runId,
+  rootCauseSelection,
+}: AutofixRootCauseProps) {
+  const [selectedId, setSelectedId] = useState(() => causes[0].id);
+
+  if (rootCauseSelection) {
+    if ('custom_root_cause' in rootCauseSelection) {
+      return (
+        <CausesContainer>
+          <CausesHeader>
+            <TruncatedContent>
+              {t('Custom response given: %s', rootCauseSelection.custom_root_cause)}
+            </TruncatedContent>
+          </CausesHeader>
+        </CausesContainer>
+      );
+    }
+
+    const selectedCause = causes.find(cause => cause.id === rootCauseSelection.cause_id);
+    const selectedFix = selectedCause?.suggested_fixes?.find(
+      fix => fix.id === rootCauseSelection.fix_id
+    );
+
+    if (!selectedCause || !selectedFix) {
+      return <Alert type="error">{t('Selected root cause not found.')}</Alert>;
+    }
+
+    const otherCauses = causes.filter(cause => cause.id !== selectedCause.id);
+
+    return (
+      <CausesContainer>
+        <SelectedRootCauseOption
+          selectedFix={selectedFix}
+          selectedCause={selectedCause}
+        />
+        {otherCauses.length > 0 && (
+          <AutofixShowMore title={t('Show unselected causes')}>
+            {otherCauses.map(cause => (
+              <RootCauseOption selected key={cause.id}>
+                <Title>{t('Cause: %s', cause.title)}</Title>
+                <CauseDescription>{cause.description}</CauseDescription>
+                {cause.suggested_fixes?.map(fix => (
+                  <SuggestedFixWrapper key={fix.id}>
+                    <SuggestedFixHeader>
+                      <strong>{t('Fix: %s', fix.title)}</strong>
+                    </SuggestedFixHeader>
+                    <p>{fix.description}</p>
+                    {fix.snippet && <SuggestedFixSnippet snippet={fix.snippet} />}
+                  </SuggestedFixWrapper>
+                ))}
+              </RootCauseOption>
+            ))}
+          </AutofixShowMore>
+        )}
+      </CausesContainer>
+    );
+  }
+
+  return (
+    <CausesContainer>
+      <CausesHeader>
+        {tn(
+          'Sentry has identified %s potential root cause. You may select the presented root cause or provide your own.',
+          'Sentry has identified %s potential root causes. You may select one of the presented root causes or provide your own.',
+          causes.length
+        )}
+      </CausesHeader>
+      <OptionsPadding>
+        <OptionsWrapper>
+          {causes.map(cause => (
+            <CauseOption
+              key={cause.id}
+              cause={cause}
+              selected={cause.id === selectedId}
+              setSelectedId={setSelectedId}
+              runId={runId}
+              groupId={groupId}
+            />
+          ))}
+          <ProvideYourOwn
+            selected={selectedId === 'custom'}
+            setSelectedId={setSelectedId}
+          />
+        </OptionsWrapper>
+      </OptionsPadding>
+    </CausesContainer>
+  );
+}
+
+const CausesContainer = styled('div')``;
+
+const CausesHeader = styled('div')`
+  padding: 0 ${space(2)};
+`;
+
+const OptionsPadding = styled('div')`
+  padding: ${space(2)};
+`;
+const OptionsWrapper = styled('div')`
+  border: 1px solid ${p => p.theme.innerBorder};
+  border-radius: ${p => p.theme.borderRadius};
+  overflow: hidden;
+  box-shadow: ${p => p.theme.dropShadowMedium};
+`;
+
+const RootCauseOption = styled('div')<{selected: boolean}>`
+  position: relative;
+  padding: ${space(2)};
+  background: ${p => (p.selected ? p.theme.background : p.theme.backgroundElevated)};
+  cursor: ${p => (p.selected ? 'default' : 'pointer')};
+
+  :not(:first-child) {
+    border-top: 1px solid ${p => p.theme.innerBorder};
+  }
+`;
+
+const RootCauseOptionHeader = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: ${space(1)};
+`;
+
+const Title = styled('div')`
+  font-weight: bold;
+`;
+
+const CauseDescription = styled('div')`
+  font-size: ${p => p.theme.fontSizeMedium};
+  margin-top: ${space(1)};
+`;
+
+const SuggestedFixWrapper = styled('div')`
+  padding: ${space(2)};
+  border: 1px solid ${p => p.theme.alert.info.border};
+  background-color: ${p => p.theme.alert.info.backgroundLight};
+  border-radius: ${p => p.theme.borderRadius};
+  margin-top: ${space(1)};
+
+  p {
+    margin: ${space(1)} 0 0 0;
+  }
+`;
+
+const SuggestedFixHeader = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  gap: ${space(1)};
+  margin-bottom: ${space(1)};
+`;
+
+const StyledCodeSnippet = styled(CodeSnippet)`
+  margin-top: ${space(2)};
+`;
+
+const ContentWrapper = styled(motion.div)<{selected: boolean}>`
+  display: grid;
+  grid-template-rows: ${p => (p.selected ? '1fr' : '0fr')};
+  transition: grid-template-rows 300ms;
+  will-change: grid-template-rows;
+
+  > div {
+    /* So that focused element outlines don't get cut off */
+    padding: 0 1px;
+    overflow: hidden;
+  }
+`;
+
+const AnimationWrapper = styled(motion.div)``;
+
+const CustomTextArea = styled(TextArea)`
+  margin-top: ${space(2)};
+`;
+
+const OptionFooter = styled('div')`
+  display: flex;
+  justify-content: flex-end;
+  margin-top: ${space(2)};
+`;
+
+const TruncatedContent = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+`;

+ 56 - 0
static/app/components/events/autofix/autofixShowMore.tsx

@@ -0,0 +1,56 @@
+import {type ReactNode, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {IconChevron} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+type AutofixShowMore = {
+  children: ReactNode;
+  title: ReactNode;
+};
+
+export function AutofixShowMore({children, title}: AutofixShowMore) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <Wrapper>
+      <Header onClick={() => setIsExpanded(value => !value)}>
+        <InteractionStateLayer />
+        <Title>{title}</Title>
+        <Button
+          icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
+          aria-label={t('Toggle details')}
+          aria-expanded={isExpanded}
+          size="zero"
+          borderless
+        />
+      </Header>
+      {isExpanded ? <Content>{children}</Content> : null}
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled('div')`
+  border-top: 1px solid ${p => p.theme.border};
+  margin-top: ${space(1.5)};
+`;
+
+const Title = styled('div')`
+  font-weight: bold;
+`;
+
+const Header = styled('div')`
+  position: relative;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  padding: ${space(1)} ${space(2)};
+  cursor: pointer;
+  user-select: none;
+`;
+
+const Content = styled('div')`
+  margin-top: ${space(1)};
+`;

+ 94 - 19
static/app/components/events/autofix/autofixSteps.tsx

@@ -3,24 +3,42 @@ import styled from '@emotion/styled';
 
 import {Button} from 'sentry/components/button';
 import DateTime from 'sentry/components/dateTime';
-import type {
-  AutofixData,
-  AutofixProgressItem,
-  AutofixStep,
+import {AutofixChanges} from 'sentry/components/events/autofix/autofixChanges';
+import {AutofixRootCause} from 'sentry/components/events/autofix/autofixRootCause';
+import {
+  type AutofixData,
+  type AutofixProgressItem,
+  type AutofixStep,
+  AutofixStepType,
 } from 'sentry/components/events/autofix/types';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import Panel from 'sentry/components/panels/panel';
-import {IconCheckmark, IconChevron, IconClose, IconFatal} from 'sentry/icons';
+import {
+  IconCheckmark,
+  IconChevron,
+  IconClose,
+  IconCode,
+  IconFatal,
+  IconQuestion,
+} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import usePrevious from 'sentry/utils/usePrevious';
 
-interface StepIconProps {
-  status: AutofixStep['status'];
-}
+function StepIcon({step}: {step: AutofixStep}) {
+  if (step.type === AutofixStepType.CHANGES) {
+    return <IconCode size="sm" color="gray300" />;
+  }
+
+  if (step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS) {
+    return step.selection ? (
+      <IconCheckmark size="sm" color="green300" isCircled />
+    ) : (
+      <IconQuestion size="sm" color="gray300" />
+    );
+  }
 
-function StepIcon({status}: StepIconProps) {
-  switch (status) {
+  switch (step.status) {
     case 'PROCESSING':
       return <ProcessingStatusIndicator size={14} mini hideMessage />;
     case 'CANCELLED':
@@ -34,7 +52,22 @@ function StepIcon({status}: StepIconProps) {
   }
 }
 
+function stepShouldBeginExpanded(step: AutofixStep) {
+  if (step.type === AutofixStepType.CHANGES) {
+    return true;
+  }
+
+  if (step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS) {
+    return step.selection ? false : true;
+  }
+
+  return step.status !== 'COMPLETED';
+}
+
 interface StepProps {
+  groupId: string;
+  onRetry: () => void;
+  runId: string;
   step: AutofixStep;
   isChild?: boolean;
   stepNumber?: number;
@@ -42,6 +75,9 @@ interface StepProps {
 
 interface AutofixStepsProps {
   data: AutofixData;
+  groupId: string;
+  onRetry: () => void;
+  runId: string;
 }
 
 function isProgressLog(
@@ -50,7 +86,17 @@ function isProgressLog(
   return 'message' in item && 'timestamp' in item;
 }
 
-function Progress({progress}: {progress: AutofixProgressItem | AutofixStep}) {
+function Progress({
+  progress,
+  groupId,
+  runId,
+  onRetry,
+}: {
+  groupId: string;
+  onRetry: () => void;
+  progress: AutofixProgressItem | AutofixStep;
+  runId: string;
+}) {
   if (isProgressLog(progress)) {
     return (
       <Fragment>
@@ -62,15 +108,15 @@ function Progress({progress}: {progress: AutofixProgressItem | AutofixStep}) {
 
   return (
     <ProgressStepContainer>
-      <Step step={progress} isChild />
+      <Step step={progress} isChild groupId={groupId} runId={runId} onRetry={onRetry} />
     </ProgressStepContainer>
   );
 }
 
-export function Step({step, isChild}: StepProps) {
+export function Step({step, isChild, groupId, runId, onRetry}: StepProps) {
   const previousStepStatus = usePrevious(step.status);
   const isActive = step.status !== 'PENDING' && step.status !== 'CANCELLED';
-  const [isExpanded, setIsExpanded] = useState(step.status !== 'COMPLETED');
+  const [isExpanded, setIsExpanded] = useState(() => stepShouldBeginExpanded(step));
 
   useEffect(() => {
     if (
@@ -84,7 +130,11 @@ export function Step({step, isChild}: StepProps) {
 
   const logs: AutofixProgressItem[] = step.progress?.filter(isProgressLog) ?? [];
   const activeLog = step.completedMessage ?? logs.at(-1)?.message ?? null;
-  const hasContent = Boolean(step.completedMessage || step.progress?.length);
+  const hasContent = Boolean(
+    step.completedMessage ||
+      step.progress?.length ||
+      step.type !== AutofixStepType.DEFAULT
+  );
   const canToggle = Boolean(isActive && hasContent);
 
   return (
@@ -100,7 +150,7 @@ export function Step({step, isChild}: StepProps) {
       >
         <StepHeaderLeft>
           <StepIconContainer>
-            <StepIcon status={step.status} />
+            <StepIcon step={step} />
           </StepIconContainer>
           <StepTitle>{step.title}</StepTitle>
           {activeLog && !isExpanded && (
@@ -125,21 +175,45 @@ export function Step({step, isChild}: StepProps) {
           {step.progress && step.progress.length > 0 ? (
             <ProgressContainer>
               {step.progress.map((progress, i) => (
-                <Progress progress={progress} key={i} />
+                <Progress
+                  progress={progress}
+                  key={i}
+                  groupId={groupId}
+                  runId={runId}
+                  onRetry={onRetry}
+                />
               ))}
             </ProgressContainer>
           ) : null}
+          {step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS && (
+            <AutofixRootCause
+              groupId={groupId}
+              runId={runId}
+              causes={step.causes}
+              rootCauseSelection={step.selection}
+            />
+          )}
+          {step.type === AutofixStepType.CHANGES && (
+            <AutofixChanges step={step} groupId={groupId} onRetry={onRetry} />
+          )}
         </Fragment>
       )}
     </StepCard>
   );
 }
 
-export function AutofixSteps({data}: AutofixStepsProps) {
+export function AutofixSteps({data, groupId, runId, onRetry}: AutofixStepsProps) {
   return (
     <div>
       {data.steps?.map((step, index) => (
-        <Step step={step} key={step.id} stepNumber={index + 1} />
+        <Step
+          step={step}
+          key={step.id}
+          stepNumber={index + 1}
+          groupId={groupId}
+          runId={runId}
+          onRetry={onRetry}
+        />
       ))}
     </div>
   );
@@ -159,6 +233,7 @@ const StepHeader = styled('div')<{canToggle: boolean; isChild?: boolean}>`
   justify-content: space-between;
   align-items: center;
   padding: ${space(2)};
+  gap: ${space(1)};
   font-size: ${p => p.theme.fontSizeMedium};
   font-family: ${p => p.theme.text.family};
   cursor: ${p => (p.canToggle ? 'pointer' : 'default')};

+ 0 - 134
static/app/components/events/autofix/fixResult.tsx

@@ -1,134 +0,0 @@
-import {Fragment} from 'react';
-import styled from '@emotion/styled';
-
-import {Button, LinkButton} from 'sentry/components/button';
-import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
-import type {AutofixData} from 'sentry/components/events/autofix/types';
-import ExternalLink from 'sentry/components/links/externalLink';
-import Panel from 'sentry/components/panels/panel';
-import {IconOpen} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-
-type Props = {
-  autofixData: AutofixData;
-  onRetry: () => void;
-};
-
-const makeGithubRepoUrl = (repoName: string) => {
-  return `https://github.com/${repoName}/`;
-};
-
-function AutofixResultContent({autofixData, onRetry}: Props) {
-  if (autofixData.status === 'ERROR') {
-    return (
-      <Content>
-        <PreviewContent>
-          {autofixData.error_message ? (
-            <Fragment>
-              <PrefixText>{t('Something went wrong')}</PrefixText>
-              {autofixData.error_message && <span>{autofixData.error_message}</span>}
-            </Fragment>
-          ) : (
-            <span>{t('Something went wrong.')}</span>
-          )}
-        </PreviewContent>
-        <Actions>
-          <Button size="xs" onClick={onRetry}>
-            {t('Try Again')}
-          </Button>
-        </Actions>
-      </Content>
-    );
-  }
-
-  if (!autofixData.fix) {
-    return (
-      <Content>
-        <PreviewContent>
-          <span>{t('Could not find a fix.')}</span>
-        </PreviewContent>
-        <Actions>
-          <Button size="xs" onClick={onRetry}>
-            {t('Try Again')}
-          </Button>
-        </Actions>
-      </Content>
-    );
-  }
-
-  return (
-    <Content>
-      <AutofixDiff diff={autofixData.fix.diff ?? []} />
-      <PreviewContent>
-        <PrefixText>
-          {tct('Pull request #[prNumber] created in [repository]', {
-            prNumber: autofixData.fix.pr_number,
-            repository: (
-              <ExternalLink href={makeGithubRepoUrl(autofixData.fix.repo_name)}>
-                {autofixData.fix.repo_name}
-              </ExternalLink>
-            ),
-          })}
-        </PrefixText>
-        <PrTitle>{autofixData.fix.title}</PrTitle>
-      </PreviewContent>
-      <Actions>
-        <LinkButton
-          size="xs"
-          icon={<IconOpen size="xs" />}
-          href={autofixData.fix.pr_url}
-          external
-        >
-          {t('View Pull Request')}
-        </LinkButton>
-      </Actions>
-    </Content>
-  );
-}
-
-export function AutofixResult({autofixData, onRetry}: Props) {
-  if (autofixData.status === 'PROCESSING') {
-    return null;
-  }
-
-  return (
-    <ResultPanel>
-      <Title>{t('Review Suggested Fix')}</Title>
-      <AutofixResultContent autofixData={autofixData} onRetry={onRetry} />
-    </ResultPanel>
-  );
-}
-
-const ResultPanel = styled(Panel)`
-  padding: ${space(2)};
-  margin: ${space(2)} 0 0 0;
-`;
-
-const PreviewContent = styled('div')`
-  display: flex;
-  flex-direction: column;
-  color: ${p => p.theme.textColor};
-  margin-top: ${space(2)};
-`;
-
-const PrefixText = styled('span')``;
-
-const PrTitle = styled('div')`
-  font-size: ${p => p.theme.fontSizeMedium};
-  font-weight: 600;
-  color: ${p => p.theme.textColor};
-`;
-
-const Content = styled('div')``;
-
-const Title = styled('div')`
-  font-weight: bold;
-  margin-bottom: ${space(2)};
-`;
-
-const Actions = styled('div')`
-  display: flex;
-  justify-content: flex-end;
-  margin-top: ${space(1)};
-`;

+ 96 - 74
static/app/components/events/autofix/index.spec.tsx

@@ -7,17 +7,15 @@ import {GroupFixture} from 'sentry-fixture/group';
 import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 import {Autofix} from 'sentry/components/events/autofix';
-import type {EventMetadataWithAutofix} from 'sentry/components/events/autofix/types';
+import {AutofixStepType} from 'sentry/components/events/autofix/types';
 
 const group = GroupFixture();
 const event = EventFixture();
 
 describe('Autofix', () => {
   beforeEach(() => {
-    MockApiClient.addMockResponse({
-      url: `/issues/${group.id}/ai-autofix/`,
-      body: null,
-    });
+    jest.clearAllMocks();
+    MockApiClient.clearMockResponses();
     MockApiClient.addMockResponse({
       url: `/issues/${group.id}/autofix/setup/`,
       body: {
@@ -28,6 +26,11 @@ describe('Autofix', () => {
   });
 
   it('renders the Banner component when autofixData is null', () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${group.id}/autofix/`,
+      body: null,
+    });
+
     render(<Autofix event={event} group={group} />);
 
     expect(screen.getByText('Try Autofix')).toBeInTheDocument();
@@ -38,6 +41,7 @@ describe('Autofix', () => {
       steps: [
         AutofixStepFixture({
           id: '1',
+          status: 'PROCESSING',
           progress: [
             AutofixProgressItemFixture({message: 'First log message'}),
             AutofixProgressItemFixture({message: 'Second log message'}),
@@ -47,24 +51,14 @@ describe('Autofix', () => {
     });
 
     MockApiClient.addMockResponse({
-      url: `/issues/${group.id}/ai-autofix/`,
-      body: autofixData,
+      url: `/issues/${group.id}/autofix/`,
+      body: {autofix: autofixData},
     });
 
-    render(
-      <Autofix
-        event={event}
-        group={{
-          ...group,
-          metadata: {
-            autofix: autofixData,
-          },
-        }}
-      />
-    );
+    render(<Autofix event={event} group={group} />);
 
     // Logs should be visible
-    expect(screen.getByText('First log message')).toBeInTheDocument();
+    expect(await screen.findByText('First log message')).toBeInTheDocument();
     expect(screen.getByText('Second log message')).toBeInTheDocument();
 
     // Toggling step hides old logs
@@ -76,40 +70,22 @@ describe('Autofix', () => {
 
   it('can reset and try again while running', async () => {
     const autofixData = AutofixDataFixture({
-      steps: [
-        AutofixStepFixture({
-          id: '1',
-          progress: [
-            AutofixProgressItemFixture({message: 'First log message'}),
-            AutofixProgressItemFixture({message: 'Second log message'}),
-          ],
-        }),
-      ],
+      steps: [AutofixStepFixture({})],
     });
 
     MockApiClient.addMockResponse({
-      url: `/issues/${group.id}/ai-autofix/`,
-      body: autofixData,
+      url: `/issues/${group.id}/autofix/`,
+      body: {autofix: autofixData},
     });
 
     const triggerAutofixMock = MockApiClient.addMockResponse({
-      url: `/issues/${group.id}/ai-autofix/`,
+      url: `/issues/${group.id}/autofix/`,
       method: 'POST',
     });
 
-    render(
-      <Autofix
-        event={event}
-        group={{
-          ...group,
-          metadata: {
-            autofix: autofixData,
-          },
-        }}
-      />
-    );
+    render(<Autofix event={event} group={group} />);
 
-    await userEvent.click(screen.getByRole('button', {name: 'Start Over'}));
+    await userEvent.click(await screen.findByRole('button', {name: 'Start Over'}));
 
     expect(screen.getByText('Try Autofix')).toBeInTheDocument();
 
@@ -119,36 +95,82 @@ describe('Autofix', () => {
     expect(triggerAutofixMock).toHaveBeenCalledTimes(1);
   });
 
-  it('renders the FixResult component when autofixData is present', () => {
-    render(
-      <Autofix
-        event={event}
-        group={{
-          ...group,
-          metadata: {
-            autofix: {
-              status: 'COMPLETED',
-              completed_at: '',
-              created_at: '',
-              fix: {
-                title: 'Fixed the bug!',
-                pr_number: 123,
-                description: 'This is a description',
-                pr_url: 'https://github.com/pulls/1234',
-                repo_name: 'getsentry/sentry',
-                diff: [],
-              },
-              steps: [],
-            },
-          } as EventMetadataWithAutofix,
-        }}
-      />
-    );
-
-    expect(screen.getByText('Fixed the bug!')).toBeInTheDocument();
-    expect(screen.getByRole('button', {name: 'View Pull Request'})).toHaveAttribute(
-      'href',
-      'https://github.com/pulls/1234'
-    );
+  it('renders the root cause component when changes step is present', async () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${group.id}/autofix/`,
+      body: {
+        autofix: AutofixDataFixture({
+          steps: [
+            AutofixStepFixture({
+              type: AutofixStepType.ROOT_CAUSE_ANALYSIS,
+              title: 'Root Cause',
+              causes: [
+                {
+                  actionability: 1,
+                  id: 'cause-1',
+                  likelihood: 1,
+                  title: 'Test Cause Title',
+                  description: 'Test Cause Description',
+                  suggested_fixes: [
+                    {
+                      id: 'fix-1',
+                      title: 'Test Fix Title',
+                      description: 'Test Fix Description',
+                      elegance: 1,
+                      snippet: {
+                        file_path: 'test/file/path.py',
+                        snippet: 'two = 1 + 1',
+                      },
+                    },
+                  ],
+                },
+              ],
+            }),
+          ],
+        }),
+      },
+    });
+
+    render(<Autofix event={event} group={group} />);
+
+    expect(await screen.findByText('Root Cause')).toBeInTheDocument();
+    expect(
+      screen.getByText(/Sentry has identified 1 potential root cause/)
+    ).toBeInTheDocument();
+    expect(screen.getByText('Test Cause Title')).toBeInTheDocument();
+    expect(screen.getByText('Test Cause Description')).toBeInTheDocument();
+  });
+
+  it('renders the diff component when changes step is present', async () => {
+    MockApiClient.addMockResponse({
+      url: `/issues/${group.id}/autofix/`,
+      body: {
+        autofix: AutofixDataFixture({
+          steps: [
+            AutofixStepFixture({
+              type: AutofixStepType.CHANGES,
+              title: 'Review Fix',
+              changes: [
+                {
+                  title: 'Test PR Title',
+                  description: 'Test PR Description',
+                  repo_id: 1,
+                  repo_name: 'getsentry/sentry',
+                  diff: [],
+                },
+              ],
+            }),
+          ],
+        }),
+      },
+    });
+
+    render(<Autofix event={event} group={group} />);
+
+    expect(await screen.findByText('Review Fix')).toBeInTheDocument();
+    expect(screen.getByText('getsentry/sentry')).toBeInTheDocument();
+    expect(
+      screen.getByRole('button', {name: 'Create a Pull Request'})
+    ).toBeInTheDocument();
   });
 });

Некоторые файлы не были показаны из-за большого количества измененных файлов