assignedTo.tsx 9.3 KB

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