autofixRootCause.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. import {Fragment, type ReactNode, useState} from 'react';
  2. import {css, keyframes} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import Alert from 'sentry/components/alert';
  7. import {Button} from 'sentry/components/button';
  8. import ClippedBox from 'sentry/components/clippedBox';
  9. import {CodeSnippet} from 'sentry/components/codeSnippet';
  10. import {ExpandableInsightContext} from 'sentry/components/events/autofix/autofixInsightCards';
  11. import {AutofixShowMore} from 'sentry/components/events/autofix/autofixShowMore';
  12. import {
  13. type AutofixRepository,
  14. type AutofixRootCauseCodeContext,
  15. type AutofixRootCauseData,
  16. type AutofixRootCauseSelection,
  17. AutofixStatus,
  18. AutofixStepType,
  19. type CodeSnippetContext,
  20. } from 'sentry/components/events/autofix/types';
  21. import {
  22. type AutofixResponse,
  23. makeAutofixQueryKey,
  24. } from 'sentry/components/events/autofix/useAutofix';
  25. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  26. import ExternalLink from 'sentry/components/links/externalLink';
  27. import {Tooltip} from 'sentry/components/tooltip';
  28. import {IconCode, IconFocus, IconRefresh} from 'sentry/icons';
  29. import {t} from 'sentry/locale';
  30. import {space} from 'sentry/styles/space';
  31. import {getFileExtension} from 'sentry/utils/fileExtension';
  32. import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
  33. import marked, {singleLineRenderer} from 'sentry/utils/marked';
  34. import {getPrismLanguage} from 'sentry/utils/prism';
  35. import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
  36. import testableTransition from 'sentry/utils/testableTransition';
  37. import useApi from 'sentry/utils/useApi';
  38. type AutofixRootCauseProps = {
  39. causes: AutofixRootCauseData[];
  40. groupId: string;
  41. repos: AutofixRepository[];
  42. rootCauseSelection: AutofixRootCauseSelection;
  43. runId: string;
  44. terminationReason?: string;
  45. };
  46. const contentAnimationProps: AnimationProps = {
  47. exit: {opacity: 0},
  48. initial: {opacity: 0},
  49. animate: {opacity: 1},
  50. transition: testableTransition({duration: 0.3}),
  51. };
  52. export function useSelectCause({groupId, runId}: {groupId: string; runId: string}) {
  53. const api = useApi();
  54. const queryClient = useQueryClient();
  55. return useMutation({
  56. mutationFn: (
  57. params:
  58. | {
  59. causeId: string;
  60. instruction?: string;
  61. }
  62. | {
  63. customRootCause: string;
  64. }
  65. ) => {
  66. return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
  67. method: 'POST',
  68. data:
  69. 'customRootCause' in params
  70. ? {
  71. run_id: runId,
  72. payload: {
  73. type: 'select_root_cause',
  74. custom_root_cause: params.customRootCause,
  75. },
  76. }
  77. : {
  78. run_id: runId,
  79. payload: {
  80. type: 'select_root_cause',
  81. cause_id: params.causeId,
  82. instruction: params.instruction,
  83. },
  84. },
  85. });
  86. },
  87. onSuccess: (_, params) => {
  88. setApiQueryData<AutofixResponse>(
  89. queryClient,
  90. makeAutofixQueryKey(groupId),
  91. data => {
  92. if (!data || !data.autofix) {
  93. return data;
  94. }
  95. return {
  96. ...data,
  97. autofix: {
  98. ...data.autofix,
  99. status: AutofixStatus.PROCESSING,
  100. steps: data.autofix.steps?.map(step => {
  101. if (step.type !== AutofixStepType.ROOT_CAUSE_ANALYSIS) {
  102. return step;
  103. }
  104. return {
  105. ...step,
  106. selection:
  107. 'customRootCause' in params
  108. ? {
  109. custom_root_cause: params.customRootCause,
  110. }
  111. : {
  112. cause_id: params.causeId,
  113. },
  114. };
  115. }),
  116. },
  117. };
  118. }
  119. );
  120. addSuccessMessage("Great, let's move forward with this root cause.");
  121. },
  122. onError: () => {
  123. addErrorMessage(t('Something went wrong when selecting the root cause.'));
  124. },
  125. });
  126. }
  127. function getLinesToHighlight(suggestedFix: AutofixRootCauseCodeContext): number[] {
  128. function findLinesWithSubstrings(
  129. input: string | undefined,
  130. substring: string
  131. ): number[] {
  132. if (!input) {
  133. return [];
  134. }
  135. const lines = input.split('\n');
  136. const result: number[] = [];
  137. lines.forEach((line, index) => {
  138. if (line.includes(substring)) {
  139. result.push(index + 1); // line numbers are 1-based
  140. }
  141. });
  142. return result;
  143. }
  144. const lineNumbersToHighlight = findLinesWithSubstrings(
  145. suggestedFix.snippet?.snippet,
  146. '***'
  147. );
  148. return lineNumbersToHighlight;
  149. }
  150. export function replaceHeadersWithBold(markdown: string) {
  151. const headerRegex = /^(#{1,6})\s+(.*)$/gm;
  152. const boldMarkdown = markdown.replace(headerRegex, (_match, _hashes, content) => {
  153. return ` **${content}** `;
  154. });
  155. return boldMarkdown;
  156. }
  157. function RootCauseDescription({cause}: {cause: AutofixRootCauseData}) {
  158. return (
  159. <CauseDescription
  160. dangerouslySetInnerHTML={{
  161. __html: marked(replaceHeadersWithBold(cause.description)),
  162. }}
  163. />
  164. );
  165. }
  166. function RootCauseContext({
  167. cause,
  168. repos,
  169. }: {
  170. cause: AutofixRootCauseData;
  171. repos: AutofixRepository[];
  172. }) {
  173. const unitTestFileExtension = cause.unit_test?.file_path
  174. ? getFileExtension(cause.unit_test.file_path)
  175. : undefined;
  176. const unitTestLanguage = unitTestFileExtension
  177. ? getPrismLanguage(unitTestFileExtension)
  178. : undefined;
  179. return (
  180. <RootCauseContextContainer>
  181. {(cause.reproduction || cause.unit_test) && (
  182. <ExpandableInsightContext
  183. icon={<IconRefresh size="sm" color="subText" />}
  184. title={'How to reproduce'}
  185. rounded
  186. >
  187. {cause.reproduction && (
  188. <CauseDescription
  189. dangerouslySetInnerHTML={{
  190. __html: marked(replaceHeadersWithBold(cause.reproduction)),
  191. }}
  192. />
  193. )}
  194. {cause.unit_test && (
  195. <Fragment>
  196. <strong>{t('Unit test that reproduces this root cause:')}</strong>
  197. <CauseDescription
  198. dangerouslySetInnerHTML={{
  199. __html: marked(replaceHeadersWithBold(cause.unit_test.description)),
  200. }}
  201. />
  202. <StyledCodeSnippet
  203. filename={cause.unit_test.file_path}
  204. language={unitTestLanguage}
  205. >
  206. {cause.unit_test.snippet}
  207. </StyledCodeSnippet>
  208. </Fragment>
  209. )}
  210. </ExpandableInsightContext>
  211. )}
  212. {cause?.code_context && cause.code_context.length > 0 && (
  213. <ExpandableInsightContext
  214. icon={<IconCode size="sm" color="subText" />}
  215. title={'Relevant code'}
  216. rounded
  217. expandByDefault
  218. >
  219. <AutofixRootCauseCodeContexts codeContext={cause.code_context} repos={repos} />
  220. </ExpandableInsightContext>
  221. )}
  222. </RootCauseContextContainer>
  223. );
  224. }
  225. function RootCauseContent({
  226. selected,
  227. children,
  228. }: {
  229. children: ReactNode;
  230. selected: boolean;
  231. }) {
  232. return (
  233. <ContentWrapper selected={selected}>
  234. <AnimatePresence initial={false}>
  235. {selected && (
  236. <AnimationWrapper key="content" {...contentAnimationProps}>
  237. {children}
  238. </AnimationWrapper>
  239. )}
  240. </AnimatePresence>
  241. </ContentWrapper>
  242. );
  243. }
  244. export function SuggestedFixSnippet({
  245. snippet,
  246. linesToHighlight,
  247. repos,
  248. icon,
  249. }: {
  250. linesToHighlight: number[];
  251. repos: AutofixRepository[];
  252. snippet: CodeSnippetContext;
  253. icon?: React.ReactNode;
  254. }) {
  255. function getSourceLink() {
  256. if (!repos) {
  257. return undefined;
  258. }
  259. const repo = repos.find(
  260. r => r.name === snippet.repo_name && r.provider === 'integrations:github'
  261. );
  262. if (!repo) {
  263. return undefined;
  264. }
  265. return `${repo.url}/blob/${repo.default_branch}/${snippet.file_path}${
  266. snippet.start_line && snippet.end_line
  267. ? `#L${snippet.start_line}-L${snippet.end_line}`
  268. : ''
  269. }`;
  270. }
  271. const extension = getFileExtension(snippet.file_path);
  272. const language = extension ? getPrismLanguage(extension) : undefined;
  273. const sourceLink = getSourceLink();
  274. return (
  275. <CodeSnippetWrapper>
  276. <StyledCodeSnippet
  277. filename={snippet.file_path}
  278. language={language}
  279. hideCopyButton
  280. linesToHighlight={linesToHighlight}
  281. icon={icon}
  282. >
  283. {snippet.snippet}
  284. </StyledCodeSnippet>
  285. {sourceLink && (
  286. <CodeLinkWrapper>
  287. <Tooltip title={t('Open in GitHub')} skipWrapper>
  288. <OpenInLink href={sourceLink} openInNewTab aria-label={t('GitHub')}>
  289. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  290. </OpenInLink>
  291. </Tooltip>
  292. </CodeLinkWrapper>
  293. )}
  294. </CodeSnippetWrapper>
  295. );
  296. }
  297. function CauseOption({
  298. cause,
  299. selected,
  300. setSelectedId,
  301. repos,
  302. }: {
  303. cause: AutofixRootCauseData;
  304. groupId: string;
  305. repos: AutofixRepository[];
  306. runId: string;
  307. selected: boolean;
  308. setSelectedId: (id: string) => void;
  309. }) {
  310. return (
  311. <RootCauseOption selected={selected} onClick={() => setSelectedId(cause.id)}>
  312. {!selected && <InteractionStateLayer />}
  313. <RootCauseOptionHeader>
  314. <TitleWrapper>
  315. <IconFocus size="sm" />
  316. <Title>{t('Potential Root Cause')}</Title>
  317. </TitleWrapper>
  318. </RootCauseOptionHeader>
  319. <RootCauseContent selected={selected}>
  320. <CauseTitle
  321. dangerouslySetInnerHTML={{
  322. __html: singleLineRenderer(cause.title),
  323. }}
  324. />
  325. <RootCauseDescription cause={cause} />
  326. <RootCauseContext cause={cause} repos={repos} />
  327. </RootCauseContent>
  328. </RootCauseOption>
  329. );
  330. }
  331. function SelectedRootCauseOption({
  332. selectedCause,
  333. repos,
  334. }: {
  335. codeContext: AutofixRootCauseCodeContext[];
  336. repos: AutofixRepository[];
  337. selectedCause: AutofixRootCauseData;
  338. }) {
  339. return (
  340. <RootCauseOption selected>
  341. <RootCauseOptionHeader>
  342. <TitleWrapper>
  343. <IconFocus size="sm" />
  344. <HeaderText>{t('Root Cause')}</HeaderText>
  345. </TitleWrapper>
  346. </RootCauseOptionHeader>
  347. <CauseTitle
  348. dangerouslySetInnerHTML={{
  349. __html: singleLineRenderer(selectedCause.title),
  350. }}
  351. />
  352. <RootCauseDescription cause={selectedCause} />
  353. <RootCauseContext cause={selectedCause} repos={repos} />
  354. </RootCauseOption>
  355. );
  356. }
  357. function AutofixRootCauseDisplay({
  358. causes,
  359. groupId,
  360. runId,
  361. rootCauseSelection,
  362. repos,
  363. }: AutofixRootCauseProps) {
  364. const [selectedId, setSelectedId] = useState(() => causes[0]!.id);
  365. const {isPending, mutate: handleSelectFix} = useSelectCause({groupId, runId});
  366. if (rootCauseSelection) {
  367. if ('custom_root_cause' in rootCauseSelection) {
  368. return (
  369. <CausesContainer>
  370. <CustomRootCausePadding>
  371. <HeaderText>{t('Custom Root Cause')}</HeaderText>
  372. <CauseDescription>{rootCauseSelection.custom_root_cause}</CauseDescription>
  373. </CustomRootCausePadding>
  374. </CausesContainer>
  375. );
  376. }
  377. const selectedCause = causes.find(cause => cause.id === rootCauseSelection.cause_id);
  378. if (!selectedCause) {
  379. return <Alert type="error">{t('Selected root cause not found.')}</Alert>;
  380. }
  381. const otherCauses = causes.filter(cause => cause.id !== selectedCause.id);
  382. return (
  383. <CausesContainer>
  384. <ClippedBox clipHeight={408}>
  385. <SelectedRootCauseOption
  386. codeContext={selectedCause?.code_context}
  387. selectedCause={selectedCause}
  388. repos={repos}
  389. />
  390. {otherCauses.length > 0 && (
  391. <AutofixShowMore title={t('Show unselected causes')}>
  392. {otherCauses.map(cause => (
  393. <RootCauseOption selected key={cause.id}>
  394. <RootCauseOptionHeader>
  395. <Title
  396. dangerouslySetInnerHTML={{
  397. __html: singleLineRenderer(t('Cause: %s', cause.title)),
  398. }}
  399. />
  400. <Button
  401. size="xs"
  402. onClick={() => handleSelectFix({causeId: cause.id})}
  403. busy={isPending}
  404. analyticsEventName="Autofix: Root Cause Fix Re-Selected"
  405. analyticsEventKey="autofix.root_cause_fix_selected"
  406. analyticsParams={{group_id: groupId}}
  407. >
  408. {t('Fix This Instead')}
  409. </Button>
  410. </RootCauseOptionHeader>
  411. <RootCauseDescription cause={cause} />
  412. <RootCauseContext cause={cause} repos={repos} />
  413. </RootCauseOption>
  414. ))}
  415. </AutofixShowMore>
  416. )}
  417. </ClippedBox>
  418. </CausesContainer>
  419. );
  420. }
  421. return (
  422. <PotentialCausesContainer>
  423. <ClippedBox clipHeight={408}>
  424. <OptionsPadding>
  425. {causes.map(cause => (
  426. <CauseOption
  427. key={cause.id}
  428. cause={cause}
  429. selected={cause.id === selectedId}
  430. setSelectedId={setSelectedId}
  431. runId={runId}
  432. groupId={groupId}
  433. repos={repos}
  434. />
  435. ))}
  436. </OptionsPadding>
  437. </ClippedBox>
  438. </PotentialCausesContainer>
  439. );
  440. }
  441. const cardAnimationProps: AnimationProps = {
  442. exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
  443. initial: {opacity: 0, height: 0, scale: 0.8},
  444. animate: {opacity: 1, height: 'auto', scale: 1},
  445. transition: testableTransition({
  446. duration: 1.0,
  447. height: {
  448. type: 'spring',
  449. bounce: 0.2,
  450. },
  451. scale: {
  452. type: 'spring',
  453. bounce: 0.2,
  454. },
  455. y: {
  456. type: 'tween',
  457. ease: 'easeOut',
  458. },
  459. }),
  460. };
  461. export function AutofixRootCause(props: AutofixRootCauseProps) {
  462. if (props.causes.length === 0) {
  463. return (
  464. <AnimatePresence initial>
  465. <AnimationWrapper key="card" {...cardAnimationProps}>
  466. <NoCausesPadding>
  467. <Alert type="warning">
  468. {t('No root cause found.\n\n%s', props.terminationReason ?? '')}
  469. </Alert>
  470. </NoCausesPadding>
  471. </AnimationWrapper>
  472. </AnimatePresence>
  473. );
  474. }
  475. return (
  476. <AnimatePresence initial>
  477. <AnimationWrapper key="card" {...cardAnimationProps}>
  478. <AutofixRootCauseDisplay {...props} />
  479. </AnimationWrapper>
  480. </AnimatePresence>
  481. );
  482. }
  483. export function AutofixRootCauseCodeContexts({
  484. codeContext,
  485. repos,
  486. }: {
  487. codeContext: AutofixRootCauseCodeContext[];
  488. repos: AutofixRepository[];
  489. }) {
  490. return codeContext?.map((fix, index) => (
  491. <SuggestedFixWrapper key={fix.id}>
  492. <SuggestedFixHeader>
  493. <strong
  494. dangerouslySetInnerHTML={{
  495. __html: singleLineRenderer(t('Snippet #%s: %s', index + 1, fix.title)),
  496. }}
  497. />
  498. </SuggestedFixHeader>
  499. <p
  500. dangerouslySetInnerHTML={{
  501. __html: marked(fix.description),
  502. }}
  503. />
  504. {fix.snippet && (
  505. <SuggestedFixSnippet
  506. snippet={fix.snippet}
  507. linesToHighlight={getLinesToHighlight(fix)}
  508. repos={repos}
  509. />
  510. )}
  511. </SuggestedFixWrapper>
  512. ));
  513. }
  514. const NoCausesPadding = styled('div')`
  515. padding: 0 ${space(2)};
  516. `;
  517. const CausesContainer = styled('div')`
  518. border: 2px solid ${p => p.theme.alert.success.border};
  519. border-radius: ${p => p.theme.borderRadius};
  520. overflow: hidden;
  521. box-shadow: ${p => p.theme.dropShadowMedium};
  522. `;
  523. const PotentialCausesContainer = styled(CausesContainer)`
  524. border: 2px solid ${p => p.theme.alert.info.border};
  525. `;
  526. const OptionsPadding = styled('div')`
  527. padding-left: ${space(1)};
  528. padding-right: ${space(1)};
  529. padding-top: ${space(1)};
  530. `;
  531. const RootCauseOption = styled('div')<{selected: boolean}>`
  532. background: ${p => (p.selected ? p.theme.background : p.theme.backgroundElevated)};
  533. cursor: ${p => (p.selected ? 'default' : 'pointer')};
  534. padding-top: ${space(1)};
  535. padding-left: ${space(2)};
  536. padding-right: ${space(2)};
  537. `;
  538. const RootCauseContextContainer = styled('div')`
  539. display: flex;
  540. flex-direction: column;
  541. gap: ${space(0.5)};
  542. `;
  543. const RootCauseOptionHeader = styled('div')`
  544. display: flex;
  545. justify-content: space-between;
  546. align-items: center;
  547. gap: ${space(1)};
  548. `;
  549. const TitleWrapper = styled('div')`
  550. display: flex;
  551. align-items: center;
  552. gap: ${space(1)};
  553. `;
  554. const Title = styled('div')`
  555. font-weight: ${p => p.theme.fontWeightBold};
  556. font-size: ${p => p.theme.fontSizeLarge};
  557. `;
  558. const CauseTitle = styled('div')`
  559. font-weight: ${p => p.theme.fontWeightBold};
  560. font-size: ${p => p.theme.fontSizeMedium};
  561. margin-top: ${space(1)};
  562. margin-bottom: ${space(1)};
  563. `;
  564. const CauseDescription = styled('div')`
  565. font-size: ${p => p.theme.fontSizeMedium};
  566. margin-top: ${space(1)};
  567. `;
  568. const SuggestedFixWrapper = styled('div')`
  569. margin-top: ${space(1)};
  570. margin-bottom: ${space(4)};
  571. p {
  572. margin: ${space(1)} 0 0 0;
  573. }
  574. `;
  575. const SuggestedFixHeader = styled('div')`
  576. display: flex;
  577. justify-content: space-between;
  578. gap: ${space(1)};
  579. margin-bottom: ${space(1)};
  580. `;
  581. const StyledCodeSnippet = styled(CodeSnippet)`
  582. margin-top: ${space(2)};
  583. `;
  584. const ContentWrapper = styled(motion.div)<{selected: boolean}>`
  585. display: grid;
  586. grid-template-rows: ${p => (p.selected ? '1fr' : '0fr')};
  587. transition: grid-template-rows 300ms;
  588. will-change: grid-template-rows;
  589. > div {
  590. /* So that focused element outlines don't get cut off */
  591. padding: 0 1px;
  592. overflow: hidden;
  593. }
  594. `;
  595. const AnimationWrapper = styled(motion.div)`
  596. transform-origin: top center;
  597. `;
  598. const CustomRootCausePadding = styled('div')`
  599. padding: ${space(2)} ${space(2)} ${space(2)} ${space(2)};
  600. `;
  601. const fadeIn = keyframes`
  602. from { opacity: 0; }
  603. to { opacity: 1; }
  604. `;
  605. const StyledIconWrapper = styled('span')`
  606. color: inherit;
  607. line-height: 0;
  608. `;
  609. const LinkStyles = css`
  610. align-items: center;
  611. gap: ${space(0.75)};
  612. `;
  613. const OpenInLink = styled(ExternalLink)`
  614. ${LinkStyles}
  615. color: ${p => p.theme.subText};
  616. animation: ${fadeIn} 0.2s ease-in-out forwards;
  617. &:hover {
  618. color: ${p => p.theme.textColor};
  619. }
  620. `;
  621. const CodeLinkWrapper = styled('div')`
  622. gap: ${space(1)};
  623. color: ${p => p.theme.subText};
  624. font-family: ${p => p.theme.text.family};
  625. padding: 0 ${space(1)};
  626. position: absolute;
  627. top: 8px;
  628. right: 0;
  629. `;
  630. const CodeSnippetWrapper = styled('div')`
  631. position: relative;
  632. `;
  633. const HeaderText = styled('div')`
  634. font-weight: bold;
  635. font-size: ${p => p.theme.fontSizeLarge};
  636. `;