assignedTo.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import {useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {fetchOrgMembers} from 'sentry/actionCreators/members';
  4. import {openCreateOwnershipRule} from 'sentry/actionCreators/modal';
  5. import Access from 'sentry/components/acl/access';
  6. import {
  7. AssigneeSelectorDropdown,
  8. AssigneeSelectorDropdownProps,
  9. SuggestedAssignee,
  10. } from 'sentry/components/assigneeSelectorDropdown';
  11. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  12. import {Button} from 'sentry/components/button';
  13. import {AutoCompleteRoot} from 'sentry/components/dropdownAutoComplete/menu';
  14. import {
  15. findMatchedRules,
  16. Rules,
  17. } from 'sentry/components/group/suggestedOwners/findMatchedRules';
  18. import LoadingIndicator from 'sentry/components/loadingIndicator';
  19. import * as SidebarSection from 'sentry/components/sidebarSection';
  20. import {IconChevron, IconSettings, IconUser} from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import MemberListStore from 'sentry/stores/memberListStore';
  23. import TeamStore from 'sentry/stores/teamStore';
  24. import space from 'sentry/styles/space';
  25. import type {Actor, Commit, Committer, Group, Project} from 'sentry/types';
  26. import type {Event} from 'sentry/types/event';
  27. import {defined} from 'sentry/utils';
  28. import useApi from 'sentry/utils/useApi';
  29. import useCommitters from 'sentry/utils/useCommitters';
  30. import useOrganization from 'sentry/utils/useOrganization';
  31. interface AssignedToProps {
  32. group: Group;
  33. project: Project;
  34. disableDropdown?: boolean;
  35. event?: Event;
  36. onAssign?: AssigneeSelectorDropdownProps['onAssign'];
  37. }
  38. type IssueOwner = {
  39. actor: Actor;
  40. source: 'codeowners' | 'projectOwnership' | 'suspectCommit';
  41. commits?: Commit[];
  42. rules?: Array<[string, string]> | null;
  43. };
  44. type EventOwners = {
  45. owners: Actor[];
  46. rules: Rules;
  47. };
  48. function getSuggestedReason(owner: IssueOwner) {
  49. if (owner.commits) {
  50. return t('Suspect commit author');
  51. }
  52. if (owner.rules?.length) {
  53. const firstRule = owner.rules[0];
  54. return t('Owner of %s', firstRule.join(':'));
  55. }
  56. return '';
  57. }
  58. /**
  59. * Combine the committer and ownership data into a single array, merging
  60. * users who are both owners based on having commits, and owners matching
  61. * project ownership rules into one array.
  62. *
  63. * ### The return array will include objects of the format:
  64. *
  65. * ```ts
  66. * actor: <
  67. * type, # Either user or team
  68. * SentryTypes.User, # API expanded user object
  69. * {email, id, name} # Sentry user which is *not* expanded
  70. * {email, name} # Unidentified user (from commits)
  71. * {id, name}, # Sentry team (check `type`)
  72. * >,
  73. * ```
  74. *
  75. * ### One or both of commits and rules will be present
  76. *
  77. * ```ts
  78. * commits: [...] # List of commits made by this owner
  79. * rules: [...] # Project rules matched for this owner
  80. * ```
  81. */
  82. function getOwnerList(
  83. committers: Committer[],
  84. eventOwners: EventOwners | null,
  85. assignedTo: Actor
  86. ): Omit<SuggestedAssignee, 'assignee'>[] {
  87. const owners: IssueOwner[] = committers.map(commiter => ({
  88. actor: {...commiter.author, type: 'user'},
  89. commits: commiter.commits,
  90. source: 'suspectCommit',
  91. }));
  92. eventOwners?.owners.forEach(owner => {
  93. const matchingRule = findMatchedRules(eventOwners?.rules || [], owner);
  94. const normalizedOwner: IssueOwner = {
  95. actor: owner,
  96. rules: matchingRule,
  97. source: matchingRule?.[0] === 'codeowners' ? 'codeowners' : 'projectOwnership',
  98. };
  99. const existingIdx =
  100. committers.length > 0 && owner.email && owner.type === 'user'
  101. ? owners.findIndex(o => o.actor.email === owner.email)
  102. : -1;
  103. if (existingIdx > -1) {
  104. owners[existingIdx] = {...normalizedOwner, ...owners[existingIdx]};
  105. return;
  106. }
  107. owners.push(normalizedOwner);
  108. });
  109. // Do not display current assignee
  110. const filteredOwners = owners.filter(
  111. owner => !(owner.actor.type === assignedTo?.type && owner.actor.id === assignedTo?.id)
  112. );
  113. // Convert to suggested assignee format
  114. return filteredOwners.map(owner => ({
  115. ...owner.actor,
  116. suggestedReason: getSuggestedReason(owner),
  117. }));
  118. }
  119. export function getAssignedToDisplayName(group: Group) {
  120. if (group.assignedTo?.type === 'team') {
  121. const team = TeamStore.getById(group.assignedTo.id);
  122. return `#${team?.slug ?? group.assignedTo.name}`;
  123. }
  124. if (group.assignedTo?.type === 'user') {
  125. const user = MemberListStore.getById(group.assignedTo.id);
  126. return user?.name ?? group.assignedTo.name;
  127. }
  128. return group.assignedTo?.name ?? t('No-one');
  129. }
  130. function AssignedTo({group, project, event, disableDropdown = false}: AssignedToProps) {
  131. const organization = useOrganization();
  132. const api = useApi();
  133. const [eventOwners, setEventOwners] = useState<EventOwners | null>(null);
  134. const hasStreamlineTargetingFeature = organization.features.includes(
  135. 'streamline-targeting-context'
  136. );
  137. const {data} = useCommitters(
  138. {
  139. eventId: event?.id ?? '',
  140. projectSlug: project.slug,
  141. },
  142. {
  143. notifyOnChangeProps: ['data'],
  144. enabled: hasStreamlineTargetingFeature && defined(event?.id),
  145. }
  146. );
  147. useEffect(() => {
  148. // TODO: We should check if this is already loaded
  149. fetchOrgMembers(api, organization.slug, [project.id]);
  150. }, [api, organization, project]);
  151. useEffect(() => {
  152. if (!event || !organization.features.includes('streamline-targeting-context')) {
  153. return () => {};
  154. }
  155. let unmounted = false;
  156. api
  157. .requestPromise(
  158. `/projects/${organization.slug}/${project.slug}/events/${event.id}/owners/`
  159. )
  160. .then(response => {
  161. if (unmounted) {
  162. return;
  163. }
  164. setEventOwners(response);
  165. });
  166. return () => {
  167. unmounted = true;
  168. };
  169. }, [api, event, organization, project.slug]);
  170. const owners = hasStreamlineTargetingFeature
  171. ? getOwnerList(data?.committers ?? [], eventOwners, group.assignedTo)
  172. : undefined;
  173. return (
  174. <SidebarSection.Wrap data-test-id="assigned-to">
  175. <StyledSidebarTitle>
  176. {t('Assigned To')}
  177. {hasStreamlineTargetingFeature && (
  178. <Access access={['project:write']}>
  179. <Button
  180. onClick={() => {
  181. openCreateOwnershipRule({project, organization, issueId: group.id});
  182. }}
  183. aria-label={t('Create Ownership Rule')}
  184. icon={<IconSettings />}
  185. borderless
  186. size="xs"
  187. />
  188. </Access>
  189. )}
  190. </StyledSidebarTitle>
  191. <StyledSidebarSectionContent>
  192. <AssigneeSelectorDropdown
  193. organization={organization}
  194. owners={owners}
  195. disabled={disableDropdown}
  196. id={group.id}
  197. assignedTo={group.assignedTo}
  198. >
  199. {({loading, isOpen, getActorProps}) => (
  200. <DropdownButton data-test-id="assignee-selector" {...getActorProps({})}>
  201. <ActorWrapper>
  202. {loading ? (
  203. <StyledLoadingIndicator mini size={24} />
  204. ) : group.assignedTo ? (
  205. <ActorAvatar
  206. data-test-id="assigned-avatar"
  207. actor={group.assignedTo}
  208. hasTooltip={false}
  209. size={24}
  210. />
  211. ) : (
  212. <IconWrapper>
  213. <IconUser size="md" />
  214. </IconWrapper>
  215. )}
  216. <ActorName>{getAssignedToDisplayName(group)}</ActorName>
  217. </ActorWrapper>
  218. {!disableDropdown && (
  219. <IconChevron
  220. data-test-id="assigned-to-chevron-icon"
  221. direction={isOpen ? 'up' : 'down'}
  222. />
  223. )}
  224. </DropdownButton>
  225. )}
  226. </AssigneeSelectorDropdown>
  227. </StyledSidebarSectionContent>
  228. </SidebarSection.Wrap>
  229. );
  230. }
  231. export default AssignedTo;
  232. const DropdownButton = styled('div')`
  233. display: flex;
  234. align-items: center;
  235. justify-content: space-between;
  236. gap: ${space(1)};
  237. padding-right: ${space(0.25)};
  238. `;
  239. const ActorWrapper = styled('div')`
  240. display: flex;
  241. align-items: center;
  242. gap: ${space(1)};
  243. max-width: 85%;
  244. line-height: 1;
  245. `;
  246. const IconWrapper = styled('div')`
  247. display: flex;
  248. padding: ${space(0.25)};
  249. `;
  250. const ActorName = styled('div')`
  251. line-height: 1.2;
  252. ${p => p.theme.overflowEllipsis}
  253. `;
  254. const StyledSidebarSectionContent = styled(SidebarSection.Content)`
  255. ${AutoCompleteRoot} {
  256. display: block;
  257. }
  258. `;
  259. const StyledSidebarTitle = styled(SidebarSection.Title)`
  260. display: flex;
  261. justify-content: space-between;
  262. align-items: center;
  263. margin-right: -${space(1)};
  264. `;
  265. const StyledLoadingIndicator = styled(LoadingIndicator)`
  266. width: 24px;
  267. height: 24px;
  268. margin: 0 !important;
  269. `;