index.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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. isPending: isOwnershipPending,
  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. isError: isCodeownersError,
  52. } = useApiQuery<CodeOwner[]>(codeownersQueryKey, {
  53. staleTime: Infinity,
  54. enabled: organization.features.includes('integrations-codeowners'),
  55. });
  56. const handleOwnershipSave = (newOwnership: IssueOwnership) => {
  57. setApiQueryData<IssueOwnership>(queryClient, ownershipQueryKey, data =>
  58. newOwnership ? newOwnership : data
  59. );
  60. closeModal();
  61. };
  62. const handleCodeOwnerAdded = (data: CodeOwner) => {
  63. setApiQueryData<CodeOwner[]>(queryClient, codeownersQueryKey, existingCodeowners => [
  64. data,
  65. ...(existingCodeowners || []),
  66. ]);
  67. };
  68. const handleCodeOwnerDeleted = (data: CodeOwner) => {
  69. setApiQueryData<CodeOwner[]>(queryClient, codeownersQueryKey, existingCodeowners =>
  70. (existingCodeowners || []).filter(codeowner => codeowner.id !== data.id)
  71. );
  72. };
  73. const handleCodeOwnerUpdated = (data: CodeOwner) => {
  74. setApiQueryData<CodeOwner[]>(queryClient, codeownersQueryKey, stateCodeOwners => {
  75. const existingCodeowners = stateCodeOwners || [];
  76. const index = existingCodeowners.findIndex(item => item.id === data.id);
  77. return [
  78. ...existingCodeowners.slice(0, index),
  79. data,
  80. ...existingCodeowners.slice(index + 1),
  81. ];
  82. });
  83. };
  84. const handleAddCodeOwner = () => {
  85. openModal(modalProps => (
  86. <AddCodeOwnerModal
  87. {...modalProps}
  88. organization={organization}
  89. project={project}
  90. onSave={handleCodeOwnerAdded}
  91. />
  92. ));
  93. };
  94. const disabled = !hasEveryAccess(['project:write'], {organization, project});
  95. const editOwnershipRulesDisabled = !hasEveryAccess(['project:read'], {
  96. organization,
  97. project,
  98. });
  99. const hasCodeowners = organization.features?.includes('integrations-codeowners');
  100. if (isOwnershipPending || isCodeownersLoading) {
  101. return <LoadingIndicator />;
  102. }
  103. return (
  104. <SentryDocumentTitle title={routeTitleGen(ownershipTitle, project.slug, false)}>
  105. <SettingsPageHeader
  106. title={t('Ownership Rules')}
  107. action={
  108. <ButtonBar gap={1}>
  109. {hasCodeowners && (
  110. <Access access={['org:integrations']} project={project}>
  111. {({hasAccess}) => (
  112. <Button
  113. onClick={handleAddCodeOwner}
  114. size="sm"
  115. data-test-id="add-codeowner-button"
  116. disabled={!hasAccess}
  117. >
  118. {t('Import CODEOWNERS')}
  119. </Button>
  120. )}
  121. </Access>
  122. )}
  123. <Button
  124. type="button"
  125. size="sm"
  126. icon={<IconEdit />}
  127. priority="primary"
  128. onClick={() =>
  129. openEditOwnershipRules({
  130. organization,
  131. project,
  132. ownership: ownership!,
  133. onSave: handleOwnershipSave,
  134. })
  135. }
  136. disabled={!!ownership && editOwnershipRulesDisabled}
  137. >
  138. {t('Edit Rules')}
  139. </Button>
  140. </ButtonBar>
  141. }
  142. />
  143. <TextBlock>
  144. {tct(
  145. `Auto-assign issues to users and teams. To learn more, [link:read the docs].`,
  146. {
  147. link: (
  148. <ExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  149. ),
  150. }
  151. )}
  152. </TextBlock>
  153. <PermissionAlert
  154. access={!editOwnershipRulesDisabled ? ['project:read'] : ['project:write']}
  155. project={project}
  156. />
  157. {isCodeownersError && (
  158. <Alert type="error">
  159. {t(
  160. "There was an error loading this project's codeowners. If this issue persists, consider importing it again."
  161. )}
  162. </Alert>
  163. )}
  164. <CodeOwnerErrors
  165. orgSlug={organization.slug}
  166. projectSlug={project.slug}
  167. codeowners={codeowners ?? []}
  168. />
  169. {ownership && (
  170. <ErrorBoundary mini>
  171. <OwnershipRulesTable
  172. projectRules={ownership.schema?.rules ?? []}
  173. codeowners={codeowners ?? []}
  174. />
  175. </ErrorBoundary>
  176. )}
  177. <PermissionAlert project={project} />
  178. {hasCodeowners && (
  179. <CodeOwnerFileTable
  180. project={project}
  181. codeowners={codeowners ?? []}
  182. onDelete={handleCodeOwnerDeleted}
  183. onUpdate={handleCodeOwnerUpdated}
  184. disabled={disabled}
  185. />
  186. )}
  187. {ownership && !isOwnershipError ? (
  188. <Form
  189. apiEndpoint={`/projects/${organization.slug}/${project.slug}/ownership/`}
  190. apiMethod="PUT"
  191. saveOnBlur
  192. initialData={{
  193. fallthrough: ownership.fallthrough,
  194. autoAssignment: ownership.autoAssignment,
  195. codeownersAutoSync: ownership.codeownersAutoSync,
  196. }}
  197. hideFooter
  198. >
  199. <JsonForm
  200. forms={[
  201. {
  202. title: t('Issue Owners'),
  203. fields: [
  204. {
  205. name: 'autoAssignment',
  206. type: 'choice',
  207. label: t('Prioritize Auto Assignment'),
  208. help: t(
  209. "When there's a conflict between suspect commit and ownership rules."
  210. ),
  211. choices: [
  212. [
  213. 'Auto Assign to Suspect Commits',
  214. t('Auto-assign to suspect commits'),
  215. ],
  216. ['Auto Assign to Issue Owner', t('Auto-assign to issue owner')],
  217. ['Turn off Auto-Assignment', t('Turn off auto-assignment')],
  218. ],
  219. disabled,
  220. },
  221. {
  222. name: 'codeownersAutoSync',
  223. type: 'boolean',
  224. label: t('Sync changes from CODEOWNERS'),
  225. help: t(
  226. 'We’ll update any changes you make to your CODEOWNERS files during a release.'
  227. ),
  228. disabled: disabled || !(codeowners || []).length,
  229. },
  230. ],
  231. },
  232. ]}
  233. />
  234. </Form>
  235. ) : (
  236. <Alert type="error">{t('There was an error issue owner settings.')}</Alert>
  237. )}
  238. </SentryDocumentTitle>
  239. );
  240. }