index.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import {closeModal, openEditOwnershipRules, openModal} from 'sentry/actionCreators/modal';
  2. import Access, {hasEveryAccess} from 'sentry/components/acl/access';
  3. import Alert from 'sentry/components/alert';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import ErrorBoundary from 'sentry/components/errorBoundary';
  7. import Form from 'sentry/components/forms/form';
  8. import JsonForm from 'sentry/components/forms/jsonForm';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  12. import {IconEdit} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import type {IssueOwnership} from 'sentry/types/group';
  15. import type {CodeOwner} from 'sentry/types/integrations';
  16. import type {Project} from 'sentry/types/project';
  17. import {
  18. type ApiQueryKey,
  19. setApiQueryData,
  20. useApiQuery,
  21. useQueryClient,
  22. } from 'sentry/utils/queryClient';
  23. import routeTitleGen from 'sentry/utils/routeTitle';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  26. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  27. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  28. import AddCodeOwnerModal from 'sentry/views/settings/project/projectOwnership/addCodeOwnerModal';
  29. import {CodeOwnerErrors} from 'sentry/views/settings/project/projectOwnership/codeownerErrors';
  30. import {CodeOwnerFileTable} from 'sentry/views/settings/project/projectOwnership/codeOwnerFileTable';
  31. import {OwnershipRulesTable} from 'sentry/views/settings/project/projectOwnership/ownershipRulesTable';
  32. export default function ProjectOwnership({project}: {project: Project}) {
  33. const organization = useOrganization();
  34. const queryClient = useQueryClient();
  35. const ownershipTitle = t('Ownership Rules');
  36. const ownershipQueryKey: ApiQueryKey = [
  37. `/projects/${organization.slug}/${project.slug}/ownership/`,
  38. ];
  39. const {
  40. data: ownership,
  41. isLoading: isOwnershipLoading,
  42. isError: isOwnershipError,
  43. } = useApiQuery<IssueOwnership>(ownershipQueryKey, {staleTime: Infinity});
  44. const codeownersQueryKey: ApiQueryKey = [
  45. `/projects/${organization.slug}/${project.slug}/codeowners/`,
  46. {query: {expand: ['codeMapping', 'ownershipSyntax']}},
  47. ];
  48. const {
  49. data: codeowners = [],
  50. isLoading: isCodeownersLoading,
  51. error: codeownersRequestError,
  52. } = useApiQuery<CodeOwner[]>(codeownersQueryKey, {staleTime: Infinity});
  53. const handleOwnershipSave = (newOwnership: IssueOwnership) => {
  54. setApiQueryData<IssueOwnership>(queryClient, ownershipQueryKey, data =>
  55. newOwnership ? newOwnership : data
  56. );
  57. closeModal();
  58. };
  59. const handleCodeOwnerAdded = (data: CodeOwner) => {
  60. setApiQueryData<CodeOwner[]>(queryClient, codeownersQueryKey, existingCodeowners => [
  61. data,
  62. ...(existingCodeowners || []),
  63. ]);
  64. };
  65. const handleCodeOwnerDeleted = (data: CodeOwner) => {
  66. setApiQueryData<CodeOwner[]>(queryClient, codeownersQueryKey, existingCodeowners =>
  67. (existingCodeowners || []).filter(codeowner => codeowner.id !== data.id)
  68. );
  69. };
  70. const handleCodeOwnerUpdated = (data: CodeOwner) => {
  71. setApiQueryData<CodeOwner[]>(queryClient, codeownersQueryKey, stateCodeOwners => {
  72. const existingCodeowners = stateCodeOwners || [];
  73. const index = existingCodeowners.findIndex(item => item.id === data.id);
  74. return [
  75. ...existingCodeowners.slice(0, index),
  76. data,
  77. ...existingCodeowners.slice(index + 1),
  78. ];
  79. });
  80. };
  81. const handleAddCodeOwner = () => {
  82. openModal(modalProps => (
  83. <AddCodeOwnerModal
  84. {...modalProps}
  85. organization={organization}
  86. project={project}
  87. onSave={handleCodeOwnerAdded}
  88. />
  89. ));
  90. };
  91. const disabled = !hasEveryAccess(['project:write'], {organization, project});
  92. const editOwnershipRulesDisabled = !hasEveryAccess(['project:read'], {
  93. organization,
  94. project,
  95. });
  96. const hasCodeowners = organization.features?.includes('integrations-codeowners');
  97. if (isOwnershipLoading || isCodeownersLoading) {
  98. return <LoadingIndicator />;
  99. }
  100. return (
  101. <SentryDocumentTitle title={routeTitleGen(ownershipTitle, project.slug, false)}>
  102. <SettingsPageHeader
  103. title={t('Ownership Rules')}
  104. action={
  105. <ButtonBar gap={1}>
  106. {hasCodeowners && (
  107. <Access access={['org:integrations']} project={project}>
  108. {({hasAccess}) => (
  109. <Button
  110. onClick={handleAddCodeOwner}
  111. size="sm"
  112. data-test-id="add-codeowner-button"
  113. disabled={!hasAccess}
  114. >
  115. {t('Import CODEOWNERS')}
  116. </Button>
  117. )}
  118. </Access>
  119. )}
  120. <Button
  121. type="button"
  122. size="sm"
  123. icon={<IconEdit />}
  124. priority="primary"
  125. onClick={() =>
  126. openEditOwnershipRules({
  127. organization,
  128. project,
  129. ownership: ownership!,
  130. onSave: handleOwnershipSave,
  131. })
  132. }
  133. disabled={!!ownership && editOwnershipRulesDisabled}
  134. >
  135. {t('Edit Rules')}
  136. </Button>
  137. </ButtonBar>
  138. }
  139. />
  140. <TextBlock>
  141. {tct(
  142. `Auto-assign issues to users and teams. To learn more, [link:read the docs].`,
  143. {
  144. link: (
  145. <ExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  146. ),
  147. }
  148. )}
  149. </TextBlock>
  150. <PermissionAlert
  151. access={!editOwnershipRulesDisabled ? ['project:read'] : ['project:write']}
  152. project={project}
  153. />
  154. {codeownersRequestError && (
  155. <Alert type="error">
  156. {t(
  157. "There was an error loading this project's codeowners. If this issue persists, consider importing it again."
  158. )}
  159. </Alert>
  160. )}
  161. <CodeOwnerErrors
  162. orgSlug={organization.slug}
  163. projectSlug={project.slug}
  164. codeowners={codeowners ?? []}
  165. />
  166. {ownership && (
  167. <ErrorBoundary mini>
  168. <OwnershipRulesTable
  169. projectRules={ownership.schema?.rules ?? []}
  170. codeowners={codeowners ?? []}
  171. />
  172. </ErrorBoundary>
  173. )}
  174. <PermissionAlert project={project} />
  175. {hasCodeowners && (
  176. <CodeOwnerFileTable
  177. project={project}
  178. codeowners={codeowners ?? []}
  179. onDelete={handleCodeOwnerDeleted}
  180. onUpdate={handleCodeOwnerUpdated}
  181. disabled={disabled}
  182. />
  183. )}
  184. {ownership && !isOwnershipError ? (
  185. <Form
  186. apiEndpoint={`/projects/${organization.slug}/${project.slug}/ownership/`}
  187. apiMethod="PUT"
  188. saveOnBlur
  189. initialData={{
  190. fallthrough: ownership.fallthrough,
  191. autoAssignment: ownership.autoAssignment,
  192. codeownersAutoSync: ownership.codeownersAutoSync,
  193. }}
  194. hideFooter
  195. >
  196. <JsonForm
  197. forms={[
  198. {
  199. title: t('Issue Owners'),
  200. fields: [
  201. {
  202. name: 'autoAssignment',
  203. type: 'choice',
  204. label: t('Prioritize Auto Assignment'),
  205. help: t(
  206. "When there's a conflict between suspect commit and ownership rules."
  207. ),
  208. choices: [
  209. [
  210. 'Auto Assign to Suspect Commits',
  211. t('Auto-assign to suspect commits'),
  212. ],
  213. ['Auto Assign to Issue Owner', t('Auto-assign to issue owner')],
  214. ['Turn off Auto-Assignment', t('Turn off auto-assignment')],
  215. ],
  216. disabled,
  217. },
  218. {
  219. name: 'codeownersAutoSync',
  220. type: 'boolean',
  221. label: t('Sync changes from CODEOWNERS'),
  222. help: t(
  223. 'We’ll update any changes you make to your CODEOWNERS files during a release.'
  224. ),
  225. disabled: disabled || !(codeowners || []).length,
  226. },
  227. ],
  228. },
  229. ]}
  230. />
  231. </Form>
  232. ) : (
  233. <Alert type="error">{t('There was an error issue owner settings.')}</Alert>
  234. )}
  235. </SentryDocumentTitle>
  236. );
  237. }