autofixChanges.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import ClippedBox from 'sentry/components/clippedBox';
  7. import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
  8. import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
  9. import type {
  10. AutofixChangesStep,
  11. AutofixCodebaseChange,
  12. } from 'sentry/components/events/autofix/types';
  13. import {
  14. type AutofixResponse,
  15. makeAutofixQueryKey,
  16. useAutofixData,
  17. } from 'sentry/components/events/autofix/useAutofix';
  18. import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import {IconOpen} from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
  24. import useApi from 'sentry/utils/useApi';
  25. type AutofixChangesProps = {
  26. groupId: string;
  27. onRetry: () => void;
  28. step: AutofixChangesStep;
  29. };
  30. function CreatePullRequestButton({
  31. change,
  32. groupId,
  33. }: {
  34. change: AutofixCodebaseChange;
  35. groupId: string;
  36. }) {
  37. const autofixData = useAutofixData({groupId});
  38. const api = useApi();
  39. const queryClient = useQueryClient();
  40. const [hasClickedCreatePr, setHasClickedCreatePr] = useState(false);
  41. const {mutate: createPr} = useMutation({
  42. mutationFn: () => {
  43. return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
  44. method: 'POST',
  45. data: {
  46. run_id: autofixData?.run_id,
  47. payload: {
  48. type: 'create_pr',
  49. repo_external_id: change.repo_external_id,
  50. repo_id: change.repo_id, // The repo_id is only here for temporary backwards compatibility for LA customers, and we should remove it soon.
  51. },
  52. },
  53. });
  54. },
  55. onSuccess: () => {
  56. setApiQueryData<AutofixResponse>(
  57. queryClient,
  58. makeAutofixQueryKey(groupId),
  59. data => {
  60. if (!data || !data.autofix) {
  61. return data;
  62. }
  63. return {
  64. ...data,
  65. autofix: {
  66. ...data.autofix,
  67. status: 'PROCESSING',
  68. },
  69. };
  70. }
  71. );
  72. },
  73. onError: () => {
  74. addErrorMessage(t('Failed to create a pull request'));
  75. },
  76. });
  77. useEffect(() => {
  78. if (hasClickedCreatePr && change.pull_request) {
  79. setHasClickedCreatePr(false);
  80. }
  81. }, [hasClickedCreatePr, change.pull_request]);
  82. return (
  83. <Button
  84. size="xs"
  85. onClick={() => {
  86. createPr();
  87. setHasClickedCreatePr(true);
  88. }}
  89. icon={
  90. hasClickedCreatePr && <ProcessingStatusIndicator size={14} mini hideMessage />
  91. }
  92. busy={hasClickedCreatePr}
  93. analyticsEventName="Autofix: Create PR Clicked"
  94. analyticsEventKey="autofix.create_pr_clicked"
  95. analyticsParams={{group_id: groupId}}
  96. >
  97. {t('Create a Pull Request')}
  98. </Button>
  99. );
  100. }
  101. function PullRequestLinkOrCreateButton({
  102. change,
  103. groupId,
  104. }: {
  105. change: AutofixCodebaseChange;
  106. groupId: string;
  107. }) {
  108. const {data} = useAutofixSetup({groupId});
  109. if (change.pull_request) {
  110. return (
  111. <LinkButton
  112. size="xs"
  113. icon={<IconOpen size="xs" />}
  114. href={change.pull_request.pr_url}
  115. external
  116. analyticsEventName="Autofix: View PR Clicked"
  117. analyticsEventKey="autofix.view_pr_clicked"
  118. analyticsParams={{group_id: groupId}}
  119. >
  120. {t('View Pull Request')}
  121. </LinkButton>
  122. );
  123. }
  124. if (
  125. !data?.githubWriteIntegration?.repos?.find(
  126. repo => `${repo.owner}/${repo.name}` === change.repo_name
  127. )?.ok
  128. ) {
  129. return (
  130. <Actions>
  131. <Button
  132. size="xs"
  133. onClick={() => {
  134. openModal(deps => (
  135. <AutofixSetupWriteAccessModal {...deps} groupId={groupId} />
  136. ));
  137. }}
  138. analyticsEventName="Autofix: Create PR Setup Clicked"
  139. analyticsEventKey="autofix.create_pr_setup_clicked"
  140. analyticsParams={{group_id: groupId}}
  141. title={t('Enable write access to create pull requests')}
  142. >
  143. {t('Create a Pull Request')}
  144. </Button>
  145. </Actions>
  146. );
  147. }
  148. return (
  149. <Actions>
  150. <CreatePullRequestButton change={change} groupId={groupId} />
  151. </Actions>
  152. );
  153. }
  154. function AutofixRepoChange({
  155. change,
  156. groupId,
  157. }: {
  158. change: AutofixCodebaseChange;
  159. groupId: string;
  160. }) {
  161. return (
  162. <Content>
  163. <RepoChangesHeader>
  164. <div>
  165. <Title>{change.repo_name}</Title>
  166. <PullRequestTitle>{change.title}</PullRequestTitle>
  167. </div>
  168. <PullRequestLinkOrCreateButton change={change} groupId={groupId} />
  169. </RepoChangesHeader>
  170. <AutofixDiff diff={change.diff} />
  171. </Content>
  172. );
  173. }
  174. export function AutofixChanges({step, onRetry, groupId}: AutofixChangesProps) {
  175. const data = useAutofixData({groupId});
  176. if (step.status === 'ERROR' || data?.status === 'ERROR') {
  177. return (
  178. <Content>
  179. <PreviewContent>
  180. {data?.error_message ? (
  181. <Fragment>
  182. <PrefixText>{t('Something went wrong')}</PrefixText>
  183. <span>{data.error_message}</span>
  184. </Fragment>
  185. ) : (
  186. <span>{t('Something went wrong.')}</span>
  187. )}
  188. </PreviewContent>
  189. <Actions>
  190. <Button size="xs" onClick={onRetry}>
  191. {t('Try Again')}
  192. </Button>
  193. </Actions>
  194. </Content>
  195. );
  196. }
  197. if (!step.changes.length) {
  198. return (
  199. <Content>
  200. <PreviewContent>
  201. <span>{t('Could not find a fix.')}</span>
  202. </PreviewContent>
  203. <Actions>
  204. <Button size="xs" onClick={onRetry}>
  205. {t('Try Again')}
  206. </Button>
  207. </Actions>
  208. </Content>
  209. );
  210. }
  211. return (
  212. <ChangesContainer>
  213. <ClippedBox clipHeight={408}>
  214. <HeaderText>{t('Fixes')}</HeaderText>
  215. {step.changes.map((change, i) => (
  216. <Fragment key={change.repo_external_id}>
  217. {i > 0 && <Separator />}
  218. <AutofixRepoChange change={change} groupId={groupId} />
  219. </Fragment>
  220. ))}
  221. </ClippedBox>
  222. </ChangesContainer>
  223. );
  224. }
  225. const PreviewContent = styled('div')`
  226. display: flex;
  227. flex-direction: column;
  228. color: ${p => p.theme.textColor};
  229. margin-top: ${space(2)};
  230. `;
  231. const PrefixText = styled('span')``;
  232. const ChangesContainer = styled('div')`
  233. border: 1px solid ${p => p.theme.innerBorder};
  234. border-radius: ${p => p.theme.borderRadius};
  235. overflow: hidden;
  236. box-shadow: ${p => p.theme.dropShadowHeavy};
  237. padding: ${space(2)};
  238. `;
  239. const Content = styled('div')`
  240. padding: 0 ${space(1)} ${space(1)} ${space(1)};
  241. `;
  242. const Title = styled('div')`
  243. font-weight: ${p => p.theme.fontWeightBold};
  244. margin-bottom: ${space(0.5)};
  245. `;
  246. const PullRequestTitle = styled('div')`
  247. color: ${p => p.theme.subText};
  248. `;
  249. const RepoChangesHeader = styled('div')`
  250. padding: ${space(2)} 0;
  251. display: grid;
  252. align-items: center;
  253. grid-template-columns: 1fr auto;
  254. `;
  255. const Actions = styled('div')`
  256. display: flex;
  257. justify-content: flex-end;
  258. align-items: center;
  259. margin-top: ${space(1)};
  260. `;
  261. const Separator = styled('hr')`
  262. border: none;
  263. border-top: 1px solid ${p => p.theme.innerBorder};
  264. margin: ${space(2)} -${space(2)} 0 -${space(2)};
  265. `;
  266. const HeaderText = styled('div')`
  267. font-weight: bold;
  268. font-size: 1.2em;
  269. `;
  270. const ProcessingStatusIndicator = styled(LoadingIndicator)`
  271. && {
  272. margin: 0;
  273. height: 14px;
  274. width: 14px;
  275. }
  276. `;