123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- import {useEffect, useState} from 'react';
- import styled from '@emotion/styled';
- import {fetchOrgMembers} from 'sentry/actionCreators/members';
- import {openIssueOwnershipRuleModal} from 'sentry/actionCreators/modal';
- import Access from 'sentry/components/acl/access';
- import type {
- OnAssignCallback,
- SuggestedAssignee,
- } from 'sentry/components/assigneeSelectorDropdown';
- import {AssigneeSelectorDropdown} from 'sentry/components/assigneeSelectorDropdown';
- import GuideAnchor from 'sentry/components/assistant/guideAnchor';
- import ActorAvatar from 'sentry/components/avatar/actorAvatar';
- import {Button} from 'sentry/components/button';
- import {AutoCompleteRoot} from 'sentry/components/dropdownAutoComplete/menu';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import * as SidebarSection from 'sentry/components/sidebarSection';
- import {IconChevron, IconSettings, IconUser} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import MemberListStore from 'sentry/stores/memberListStore';
- import TeamStore from 'sentry/stores/teamStore';
- import {space} from 'sentry/styles/space';
- import type {Actor, Commit, Committer, Group, Project} from 'sentry/types';
- import type {Event} from 'sentry/types/event';
- import {defined} from 'sentry/utils';
- import type {FeedbackIssue} from 'sentry/utils/feedback/types';
- import useApi from 'sentry/utils/useApi';
- import useCommitters from 'sentry/utils/useCommitters';
- import useOrganization from 'sentry/utils/useOrganization';
- // TODO(ts): add the correct type
- type Rules = Array<any> | null;
- /**
- * Given a list of rule objects returned from the API, locate the matching
- * rules for a specific owner.
- */
- function findMatchedRules(rules: Rules, owner: Actor) {
- if (!rules) {
- return undefined;
- }
- const matchOwner = (actorType: Actor['type'], key: string) =>
- (actorType === 'user' && key === owner.email) ||
- (actorType === 'team' && key === owner.name);
- const actorHasOwner = ([actorType, key]) =>
- actorType === owner.type && matchOwner(actorType, key);
- return rules
- .filter(([_, ruleActors]) => ruleActors.find(actorHasOwner))
- .map(([rule]) => rule);
- }
- interface AssignedToProps {
- group: Group;
- project: Project;
- disableDropdown?: boolean;
- event?: Event;
- onAssign?: OnAssignCallback;
- }
- type IssueOwner = {
- actor: Actor;
- source: 'codeowners' | 'projectOwnership' | 'suspectCommit';
- commits?: Commit[];
- rules?: Array<[string, string]> | null;
- };
- export type EventOwners = {
- owners: Actor[];
- rules: Rules;
- };
- function getSuggestedReason(owner: IssueOwner) {
- if (owner.commits) {
- return t('Suspect commit author');
- }
- if (owner.rules?.length) {
- const firstRule = owner.rules[0];
- return t('Owner of %s', firstRule.join(':'));
- }
- return '';
- }
- /**
- * Combine the committer and ownership data into a single array, merging
- * users who are both owners based on having commits, and owners matching
- * project ownership rules into one array.
- *
- * ### The return array will include objects of the format:
- *
- * ```ts
- * actor: <
- * type, # Either user or team
- * {User}, # API expanded user object
- * {email, id, name} # Sentry user which is *not* expanded
- * {email, name} # Unidentified user (from commits)
- * {id, name}, # Sentry team (check `type`)
- * >,
- * ```
- *
- * ### One or both of commits and rules will be present
- *
- * ```ts
- * commits: [...] # List of commits made by this owner
- * rules: [...] # Project rules matched for this owner
- * ```
- */
- export function getOwnerList(
- committers: Committer[],
- eventOwners: EventOwners | null,
- assignedTo: Actor | null
- ): Omit<SuggestedAssignee, 'assignee'>[] {
- const owners: IssueOwner[] = committers.map(commiter => ({
- actor: {...commiter.author, type: 'user'},
- commits: commiter.commits,
- source: 'suspectCommit',
- }));
- eventOwners?.owners.forEach(owner => {
- const matchingRule = findMatchedRules(eventOwners?.rules || [], owner);
- const normalizedOwner: IssueOwner = {
- actor: owner,
- rules: matchingRule,
- source: matchingRule?.[0] === 'codeowners' ? 'codeowners' : 'projectOwnership',
- };
- const existingIdx =
- committers.length > 0 && owner.email && owner.type === 'user'
- ? owners.findIndex(o => o.actor.email === owner.email)
- : -1;
- if (existingIdx > -1) {
- owners[existingIdx] = {...normalizedOwner, ...owners[existingIdx]};
- return;
- }
- owners.push(normalizedOwner);
- });
- // Do not display current assignee
- const filteredOwners = owners.filter(
- owner => !(owner.actor.type === assignedTo?.type && owner.actor.id === assignedTo?.id)
- );
- // Convert to suggested assignee format
- return filteredOwners.map<Omit<SuggestedAssignee, 'assignee'>>(owner => ({
- ...owner.actor,
- suggestedReasonText: getSuggestedReason(owner),
- suggestedReason: owner.source,
- }));
- }
- export function getAssignedToDisplayName(group: Group | FeedbackIssue) {
- if (group.assignedTo?.type === 'team') {
- const team = TeamStore.getById(group.assignedTo.id);
- return `#${team?.slug ?? group.assignedTo.name}`;
- }
- if (group.assignedTo?.type === 'user') {
- const user = MemberListStore.getById(group.assignedTo.id);
- return user?.name ?? group.assignedTo.name;
- }
- return group.assignedTo?.name;
- }
- function AssignedTo({
- group,
- project,
- event,
- onAssign,
- disableDropdown = false,
- }: AssignedToProps) {
- const organization = useOrganization();
- const api = useApi();
- const [eventOwners, setEventOwners] = useState<EventOwners | null>(null);
- const {data} = useCommitters(
- {
- eventId: event?.id ?? '',
- projectSlug: project.slug,
- },
- {
- notifyOnChangeProps: ['data'],
- enabled: defined(event?.id),
- }
- );
- useEffect(() => {
- // TODO: We should check if this is already loaded
- fetchOrgMembers(api, organization.slug, [project.id]);
- }, [api, organization, project]);
- useEffect(() => {
- if (!event) {
- return () => {};
- }
- let unmounted = false;
- api
- .requestPromise(
- `/projects/${organization.slug}/${project.slug}/events/${event.id}/owners/`
- )
- .then(response => {
- if (unmounted) {
- return;
- }
- setEventOwners(response);
- });
- return () => {
- unmounted = true;
- };
- }, [api, event, organization, project.slug]);
- const owners = getOwnerList(data?.committers ?? [], eventOwners, group.assignedTo);
- return (
- <SidebarSection.Wrap data-test-id="assigned-to">
- <StyledSidebarTitle>
- {t('Assigned To')}
- <Access access={['project:read']}>
- <GuideAnchor target="issue_sidebar_owners" position="bottom">
- <Button
- onClick={() => {
- openIssueOwnershipRuleModal({
- project,
- organization,
- issueId: group.id,
- eventData: event!,
- });
- }}
- aria-label={t('Create Ownership Rule')}
- icon={<IconSettings />}
- borderless
- size="xs"
- />
- </GuideAnchor>
- </Access>
- </StyledSidebarTitle>
- <StyledSidebarSectionContent>
- <AssigneeSelectorDropdown
- organization={organization}
- owners={owners}
- disabled={disableDropdown}
- id={group.id}
- assignedTo={group.assignedTo}
- onAssign={onAssign}
- >
- {({loading, isOpen, getActorProps}) => (
- <DropdownButton data-test-id="assignee-selector" {...getActorProps({})}>
- <ActorWrapper>
- {loading ? (
- <StyledLoadingIndicator mini size={24} />
- ) : group.assignedTo ? (
- <ActorAvatar
- data-test-id="assigned-avatar"
- actor={group.assignedTo}
- hasTooltip={false}
- size={24}
- />
- ) : (
- <IconWrapper>
- <IconUser size="md" />
- </IconWrapper>
- )}
- <ActorName>{getAssignedToDisplayName(group) ?? t('No one')}</ActorName>
- </ActorWrapper>
- {!disableDropdown && (
- <IconChevron
- data-test-id="assigned-to-chevron-icon"
- direction={isOpen ? 'up' : 'down'}
- />
- )}
- </DropdownButton>
- )}
- </AssigneeSelectorDropdown>
- </StyledSidebarSectionContent>
- </SidebarSection.Wrap>
- );
- }
- export default AssignedTo;
- const DropdownButton = styled('div')`
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: ${space(1)};
- padding-right: ${space(0.25)};
- `;
- const ActorWrapper = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
- max-width: 85%;
- line-height: 1;
- `;
- const IconWrapper = styled('div')`
- display: flex;
- padding: ${space(0.25)};
- `;
- const ActorName = styled('div')`
- line-height: 1.2;
- ${p => p.theme.overflowEllipsis}
- `;
- const StyledSidebarSectionContent = styled(SidebarSection.Content)`
- ${AutoCompleteRoot} {
- display: block;
- }
- `;
- const StyledSidebarTitle = styled(SidebarSection.Title)`
- justify-content: space-between;
- margin-right: -${space(1)};
- `;
- const StyledLoadingIndicator = styled(LoadingIndicator)`
- width: 24px;
- height: 24px;
- margin: 0 !important;
- `;
|