autofixChanges.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. import {Fragment, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {openModal} from 'sentry/actionCreators/modal';
  6. import {Button, LinkButton} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import ClippedBox from 'sentry/components/clippedBox';
  9. import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
  10. import {useUpdateInsightCard} from 'sentry/components/events/autofix/autofixInsightCards';
  11. import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
  12. import {
  13. type AutofixChangesStep,
  14. type AutofixCodebaseChange,
  15. AutofixStatus,
  16. AutofixStepType,
  17. } from 'sentry/components/events/autofix/types';
  18. import {
  19. makeAutofixQueryKey,
  20. useAutofixData,
  21. } from 'sentry/components/events/autofix/useAutofix';
  22. import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup';
  23. import LoadingIndicator from 'sentry/components/loadingIndicator';
  24. import {ScrollCarousel} from 'sentry/components/scrollCarousel';
  25. import {IconCopy, IconFix, IconOpen} from 'sentry/icons';
  26. import {t} from 'sentry/locale';
  27. import {space} from 'sentry/styles/space';
  28. import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
  29. import testableTransition from 'sentry/utils/testableTransition';
  30. import useApi from 'sentry/utils/useApi';
  31. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  32. type AutofixChangesProps = {
  33. groupId: string;
  34. runId: string;
  35. step: AutofixChangesStep;
  36. previousDefaultStepIndex?: number;
  37. previousInsightCount?: number;
  38. };
  39. function AutofixRepoChange({
  40. change,
  41. groupId,
  42. runId,
  43. previousDefaultStepIndex,
  44. previousInsightCount,
  45. }: {
  46. change: AutofixCodebaseChange;
  47. groupId: string;
  48. runId: string;
  49. previousDefaultStepIndex?: number;
  50. previousInsightCount?: number;
  51. }) {
  52. return (
  53. <Content>
  54. <RepoChangesHeader>
  55. <div>
  56. <Title>{change.title}</Title>
  57. <PullRequestTitle>{change.repo_name}</PullRequestTitle>
  58. </div>
  59. </RepoChangesHeader>
  60. <AutofixDiff
  61. diff={change.diff}
  62. groupId={groupId}
  63. runId={runId}
  64. repoId={change.repo_external_id}
  65. editable={!change.pull_request}
  66. previousDefaultStepIndex={previousDefaultStepIndex}
  67. previousInsightCount={previousInsightCount}
  68. />
  69. </Content>
  70. );
  71. }
  72. const cardAnimationProps: AnimationProps = {
  73. exit: {opacity: 0, height: 0, scale: 0.8, y: -20},
  74. initial: {opacity: 0, height: 0, scale: 0.8},
  75. animate: {opacity: 1, height: 'auto', scale: 1},
  76. transition: testableTransition({
  77. duration: 1.0,
  78. height: {
  79. type: 'spring',
  80. bounce: 0.2,
  81. },
  82. scale: {
  83. type: 'spring',
  84. bounce: 0.2,
  85. },
  86. y: {
  87. type: 'tween',
  88. ease: 'easeOut',
  89. },
  90. }),
  91. };
  92. function BranchButton({change}: {change: AutofixCodebaseChange}) {
  93. const {onClick} = useCopyToClipboard({
  94. text: `git fetch --all && git switch ${change.branch_name}`,
  95. successMessage: t('Command copied. Next stop: your terminal.'),
  96. });
  97. return (
  98. <Button
  99. key={`${change.repo_external_id}-${Math.random()}`}
  100. size="xs"
  101. priority="primary"
  102. onClick={onClick}
  103. aria-label={t('Check out in %s', change.repo_name)}
  104. title={t('git fetch --all && git switch %s', change.branch_name)}
  105. icon={<IconCopy size="xs" />}
  106. >
  107. {t('Check out in %s', change.repo_name)}
  108. </Button>
  109. );
  110. }
  111. function CreatePRsButton({
  112. changes,
  113. groupId,
  114. runId,
  115. }: {
  116. changes: AutofixCodebaseChange[];
  117. groupId: string;
  118. runId: string;
  119. }) {
  120. const api = useApi();
  121. const queryClient = useQueryClient();
  122. const [hasClickedCreatePr, setHasClickedCreatePr] = useState(false);
  123. const createPRs = () => {
  124. setHasClickedCreatePr(true);
  125. for (const change of changes) {
  126. createPr({change});
  127. }
  128. };
  129. const {mutate: createPr} = useMutation({
  130. mutationFn: ({change}: {change: AutofixCodebaseChange}) => {
  131. return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
  132. method: 'POST',
  133. data: {
  134. run_id: runId,
  135. payload: {
  136. type: 'create_pr',
  137. repo_external_id: change.repo_external_id,
  138. },
  139. },
  140. });
  141. },
  142. onSuccess: () => {
  143. addSuccessMessage(t('Created pull requests.'));
  144. queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)});
  145. },
  146. onError: () => {
  147. setHasClickedCreatePr(false);
  148. addErrorMessage(t('Failed to create a pull request'));
  149. },
  150. });
  151. return (
  152. <Button
  153. priority="primary"
  154. onClick={createPRs}
  155. icon={
  156. hasClickedCreatePr && <ProcessingStatusIndicator size={14} mini hideMessage />
  157. }
  158. size="sm"
  159. busy={hasClickedCreatePr}
  160. analyticsEventName="Autofix: Create PR Clicked"
  161. analyticsEventKey="autofix.create_pr_clicked"
  162. analyticsParams={{group_id: groupId}}
  163. >
  164. Draft PR{changes.length > 1 ? 's' : ''}
  165. </Button>
  166. );
  167. }
  168. function CreateBranchButton({
  169. changes,
  170. groupId,
  171. runId,
  172. }: {
  173. changes: AutofixCodebaseChange[];
  174. groupId: string;
  175. runId: string;
  176. }) {
  177. const api = useApi();
  178. const queryClient = useQueryClient();
  179. const [hasClickedPushToBranch, setHasClickedPushToBranch] = useState(false);
  180. const pushToBranch = () => {
  181. setHasClickedPushToBranch(true);
  182. for (const change of changes) {
  183. createBranch({change});
  184. }
  185. };
  186. const {mutate: createBranch} = useMutation({
  187. mutationFn: ({change}: {change: AutofixCodebaseChange}) => {
  188. return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
  189. method: 'POST',
  190. data: {
  191. run_id: runId,
  192. payload: {
  193. type: 'create_branch',
  194. repo_external_id: change.repo_external_id,
  195. },
  196. },
  197. });
  198. },
  199. onSuccess: () => {
  200. addSuccessMessage(t('Pushed to branches.'));
  201. queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)});
  202. },
  203. onError: () => {
  204. setHasClickedPushToBranch(false);
  205. addErrorMessage(t('Failed to push to branches.'));
  206. },
  207. });
  208. return (
  209. <Button
  210. onClick={pushToBranch}
  211. icon={
  212. hasClickedPushToBranch && <ProcessingStatusIndicator size={14} mini hideMessage />
  213. }
  214. size="sm"
  215. busy={hasClickedPushToBranch}
  216. analyticsEventName="Autofix: Push to Branch Clicked"
  217. analyticsEventKey="autofix.push_to_branch_clicked"
  218. analyticsParams={{group_id: groupId}}
  219. >
  220. Check Out Locally
  221. </Button>
  222. );
  223. }
  224. function SetupAndCreateBranchButton({
  225. changes,
  226. groupId,
  227. runId,
  228. }: {
  229. changes: AutofixCodebaseChange[];
  230. groupId: string;
  231. runId: string;
  232. }) {
  233. const {data: setupData} = useAutofixSetup({groupId, checkWriteAccess: true});
  234. if (
  235. !changes.every(
  236. change =>
  237. setupData?.githubWriteIntegration?.repos?.find(
  238. repo => `${repo.owner}/${repo.name}` === change.repo_name
  239. )?.ok
  240. )
  241. ) {
  242. return (
  243. <Button
  244. onClick={() => {
  245. openModal(deps => <AutofixSetupWriteAccessModal {...deps} groupId={groupId} />);
  246. }}
  247. size="sm"
  248. analyticsEventName="Autofix: Create Branch Setup Clicked"
  249. analyticsEventKey="autofix.create_branch_setup_clicked"
  250. analyticsParams={{group_id: groupId}}
  251. title={t('Enable write access to create branches')}
  252. >
  253. {t('Check Out Locally')}
  254. </Button>
  255. );
  256. }
  257. return <CreateBranchButton changes={changes} groupId={groupId} runId={runId} />;
  258. }
  259. function SetupAndCreatePRsButton({
  260. changes,
  261. groupId,
  262. runId,
  263. }: {
  264. changes: AutofixCodebaseChange[];
  265. groupId: string;
  266. runId: string;
  267. }) {
  268. const {data: setupData} = useAutofixSetup({groupId, checkWriteAccess: true});
  269. if (
  270. !changes.every(
  271. change =>
  272. setupData?.githubWriteIntegration?.repos?.find(
  273. repo => `${repo.owner}/${repo.name}` === change.repo_name
  274. )?.ok
  275. )
  276. ) {
  277. return (
  278. <Button
  279. priority="primary"
  280. onClick={() => {
  281. openModal(deps => <AutofixSetupWriteAccessModal {...deps} groupId={groupId} />);
  282. }}
  283. size="sm"
  284. analyticsEventName="Autofix: Create PR Setup Clicked"
  285. analyticsEventKey="autofix.create_pr_setup_clicked"
  286. analyticsParams={{group_id: groupId}}
  287. title={t('Enable write access to create pull requests')}
  288. >
  289. {t('Draft PR')}
  290. </Button>
  291. );
  292. }
  293. return <CreatePRsButton changes={changes} groupId={groupId} runId={runId} />;
  294. }
  295. export function AutofixChanges({
  296. step,
  297. groupId,
  298. runId,
  299. previousDefaultStepIndex,
  300. previousInsightCount,
  301. }: AutofixChangesProps) {
  302. const data = useAutofixData({groupId});
  303. const {mutate: sendFeedbackOnChanges} = useUpdateInsightCard({groupId, runId});
  304. const handleAddTests = () => {
  305. const planStep = data?.steps?.[data.steps.length - 2];
  306. if (!planStep || planStep.type !== AutofixStepType.DEFAULT) {
  307. return;
  308. }
  309. sendFeedbackOnChanges({
  310. step_index: planStep.index,
  311. retain_insight_card_index: planStep.insights.length - 1,
  312. message:
  313. 'Please write a unit test that reproduces the issue to make sure it is fixed. Put it in the appropriate test file in the codebase. If there is none, create one.',
  314. });
  315. };
  316. if (step.status === 'ERROR' || data?.status === 'ERROR') {
  317. return (
  318. <Content>
  319. <PreviewContent>
  320. {data?.error_message ? (
  321. <Fragment>
  322. <PrefixText>{t('Something went wrong')}</PrefixText>
  323. <span>{data.error_message}</span>
  324. </Fragment>
  325. ) : (
  326. <span>{t('Something went wrong.')}</span>
  327. )}
  328. </PreviewContent>
  329. </Content>
  330. );
  331. }
  332. if (!step.changes.length) {
  333. return (
  334. <Content>
  335. <PreviewContent>
  336. <span>{t('Could not find a fix.')}</span>
  337. </PreviewContent>
  338. </Content>
  339. );
  340. }
  341. const allChangesHavePullRequests = step.changes.every(change => change.pull_request);
  342. const prsMade =
  343. step.status === AutofixStatus.COMPLETED &&
  344. step.changes.length >= 1 &&
  345. step.changes.every(change => change.pull_request);
  346. const branchesMade =
  347. !prsMade &&
  348. step.status === AutofixStatus.COMPLETED &&
  349. step.changes.length >= 1 &&
  350. step.changes.every(change => change.branch_name);
  351. return (
  352. <AnimatePresence initial>
  353. <AnimationWrapper key="card" {...cardAnimationProps}>
  354. <ChangesContainer allChangesHavePullRequests={allChangesHavePullRequests}>
  355. <ClippedBox clipHeight={408}>
  356. <HeaderWrapper>
  357. <HeaderText>
  358. <IconFix size="sm" />
  359. {t('Fixes')}
  360. </HeaderText>
  361. {!prsMade && !branchesMade ? (
  362. <ButtonBar gap={1}>
  363. <Button
  364. size="sm"
  365. onClick={handleAddTests}
  366. analyticsEventName="Autofix: Add Tests Clicked"
  367. analyticsEventKey="autofix.add_tests_clicked"
  368. analyticsParams={{group_id: groupId}}
  369. >
  370. {t('Add Tests')}
  371. </Button>
  372. <SetupAndCreateBranchButton
  373. changes={step.changes}
  374. groupId={groupId}
  375. runId={runId}
  376. />
  377. <SetupAndCreatePRsButton
  378. changes={step.changes}
  379. groupId={groupId}
  380. runId={runId}
  381. />
  382. </ButtonBar>
  383. ) : prsMade ? (
  384. step.changes.length === 1 &&
  385. step.changes[0] &&
  386. step.changes[0].pull_request?.pr_url ? (
  387. <LinkButton
  388. size="xs"
  389. priority="primary"
  390. icon={<IconOpen size="xs" />}
  391. href={step.changes[0].pull_request.pr_url}
  392. external
  393. >
  394. View PR in {step.changes[0].repo_name}
  395. </LinkButton>
  396. ) : (
  397. <StyledScrollCarousel aria-label={t('View pull requests')}>
  398. {step.changes.map(
  399. change =>
  400. change.pull_request?.pr_url && (
  401. <LinkButton
  402. key={`${change.repo_external_id}-${Math.random()}`}
  403. size="xs"
  404. priority="primary"
  405. icon={<IconOpen size="xs" />}
  406. href={change.pull_request.pr_url}
  407. external
  408. >
  409. View PR in {change.repo_name}
  410. </LinkButton>
  411. )
  412. )}
  413. </StyledScrollCarousel>
  414. )
  415. ) : branchesMade ? (
  416. step.changes.length === 1 && step.changes[0] ? (
  417. <BranchButton change={step.changes[0]} />
  418. ) : (
  419. <StyledScrollCarousel aria-label={t('Check out branches')}>
  420. {step.changes.map(
  421. change =>
  422. change.branch_name && (
  423. <BranchButton
  424. key={`${change.repo_external_id}-${Math.random()}`}
  425. change={change}
  426. />
  427. )
  428. )}
  429. </StyledScrollCarousel>
  430. )
  431. ) : null}
  432. </HeaderWrapper>
  433. {step.changes.map((change, i) => (
  434. <Fragment key={change.repo_external_id}>
  435. {i > 0 && <Separator />}
  436. <AutofixRepoChange
  437. change={change}
  438. groupId={groupId}
  439. runId={runId}
  440. previousDefaultStepIndex={previousDefaultStepIndex}
  441. previousInsightCount={previousInsightCount}
  442. />
  443. </Fragment>
  444. ))}
  445. </ClippedBox>
  446. </ChangesContainer>
  447. </AnimationWrapper>
  448. </AnimatePresence>
  449. );
  450. }
  451. const StyledScrollCarousel = styled(ScrollCarousel)`
  452. padding: 0 ${space(1)};
  453. `;
  454. const PreviewContent = styled('div')`
  455. display: flex;
  456. flex-direction: column;
  457. color: ${p => p.theme.textColor};
  458. margin-top: ${space(2)};
  459. `;
  460. const AnimationWrapper = styled(motion.div)`
  461. transform-origin: top center;
  462. `;
  463. const PrefixText = styled('span')``;
  464. const ChangesContainer = styled('div')<{allChangesHavePullRequests: boolean}>`
  465. border: 2px solid
  466. ${p =>
  467. p.allChangesHavePullRequests
  468. ? p.theme.alert.success.border
  469. : p.theme.alert.info.border};
  470. border-radius: ${p => p.theme.borderRadius};
  471. box-shadow: ${p => p.theme.dropShadowMedium};
  472. padding-left: ${space(2)};
  473. padding-right: ${space(2)};
  474. padding-top: ${space(1)};
  475. `;
  476. const Content = styled('div')`
  477. padding: 0 ${space(1)} ${space(1)} ${space(1)};
  478. `;
  479. const Title = styled('div')`
  480. font-weight: ${p => p.theme.fontWeightBold};
  481. margin-bottom: ${space(0.5)};
  482. `;
  483. const PullRequestTitle = styled('div')`
  484. color: ${p => p.theme.subText};
  485. `;
  486. const RepoChangesHeader = styled('div')`
  487. padding: ${space(2)} 0;
  488. display: grid;
  489. align-items: center;
  490. grid-template-columns: 1fr auto;
  491. `;
  492. const Separator = styled('hr')`
  493. border: none;
  494. border-top: 1px solid ${p => p.theme.innerBorder};
  495. margin: ${space(2)} -${space(2)} 0 -${space(2)};
  496. `;
  497. const HeaderText = styled('div')`
  498. font-weight: bold;
  499. font-size: 1.2em;
  500. display: flex;
  501. align-items: center;
  502. gap: ${space(1)};
  503. `;
  504. const HeaderWrapper = styled('div')`
  505. display: flex;
  506. justify-content: space-between;
  507. align-items: center;
  508. padding: 0 ${space(1)} ${space(1)} ${space(1)};
  509. border-bottom: 1px solid ${p => p.theme.border};
  510. `;
  511. const ProcessingStatusIndicator = styled(LoadingIndicator)`
  512. && {
  513. margin: 0;
  514. height: 14px;
  515. width: 14px;
  516. }
  517. `;