Browse Source

feat(codeowners): Display mappings suggestions from codeowners (#31406)

See API-2386

This PR will add suggestions to the first page of the team/user mappings pages. The suggestions come from codeowners and are specific to the provider page that the user is currently viewing (i.e. viewing the gitlab mappings should not show github account suggestions)
Leander Rodrigues 3 years ago
parent
commit
3f439a4477

+ 22 - 12
static/app/components/integrationExternalMappingForm.tsx

@@ -4,9 +4,14 @@ import capitalize from 'lodash/capitalize';
 
 import {SelectAsyncControlProps} from 'sentry/components/forms/selectAsyncControl';
 import {t, tct} from 'sentry/locale';
-import {ExternalActorMapping, Integration} from 'sentry/types';
+import {
+  ExternalActorMapping,
+  ExternalActorMappingOrSuggestion,
+  Integration,
+} from 'sentry/types';
 import {
   getExternalActorEndpointDetails,
+  isExternalActorMapping,
   sentryNameToOption,
 } from 'sentry/utils/integrationUtil';
 import {FieldFromConfig} from 'sentry/views/settings/components/forms';
@@ -17,9 +22,9 @@ import {Field} from 'sentry/views/settings/components/forms/type';
 type Props = Pick<Form['props'], 'onCancel' | 'onSubmitSuccess' | 'onSubmitError'> &
   Pick<SelectAsyncControlProps, 'defaultOptions'> & {
     integration: Integration;
-    mapping?: ExternalActorMapping;
+    mapping?: ExternalActorMappingOrSuggestion;
     type: 'user' | 'team';
-    getBaseFormEndpoint: (mapping?: ExternalActorMapping) => string;
+    getBaseFormEndpoint: (mapping?: ExternalActorMappingOrSuggestion) => string;
     sentryNamesMapper: (v: any) => {id: string; name: string}[];
     dataEndpoint: string;
     onResults?: (data: any, mappingKey?: string) => void;
@@ -39,21 +44,23 @@ export default class IntegrationExternalMappingForm extends Component<Props> {
     };
   }
 
-  getDefaultOptions(mapping?: ExternalActorMapping) {
+  getDefaultOptions(mapping?: ExternalActorMappingOrSuggestion) {
     const {defaultOptions, type} = this.props;
     if (typeof defaultOptions === 'boolean') {
       return defaultOptions;
     }
     const options = [...(defaultOptions ?? [])];
-    if (!mapping) {
+    if (!mapping || !isExternalActorMapping(mapping) || !mapping.sentryName) {
       return options;
     }
     // For organizations with >100 entries, we want to make sure their
     // saved mapping gets populated in the results if it wouldn't have
     // been in the initial 100 API results, which is why we add it here
     const mappingId = mapping[`${type}Id`];
-    const mappingOption = options.find(({value}) => mappingId && value === mappingId);
-    return !!mappingOption
+    const isMappingInOptionsAlready = options.some(
+      ({value}) => mappingId && value === mappingId
+    );
+    return isMappingInOptionsAlready
       ? options
       : [{value: mappingId, label: mapping.sentryName}, ...options];
   }
@@ -111,12 +118,15 @@ export default class IntegrationExternalMappingForm extends Component<Props> {
 
   // This function is necessary since the endpoint we submit to changes depending on the value selected
   updateModel() {
-    const mapping = this.model.getData() as ExternalActorMapping;
-    const {getBaseFormEndpoint} = this.props;
-    if (mapping) {
+    const {getBaseFormEndpoint, mapping} = this.props;
+    const updatedMapping: ExternalActorMapping = {
+      ...mapping,
+      ...(this.model.getData() as ExternalActorMapping),
+    };
+    if (updatedMapping) {
       const endpointDetails = getExternalActorEndpointDetails(
-        getBaseFormEndpoint(mapping),
-        mapping
+        getBaseFormEndpoint(updatedMapping),
+        updatedMapping
       );
       this.model.setFormOptions({...this.model.options, ...endpointDetails});
     }

+ 134 - 32
static/app/components/integrationExternalMappings.tsx

@@ -1,43 +1,123 @@
-import {Component, Fragment} from 'react';
+import {Fragment} from 'react';
+import {withRouter, WithRouterProps} from 'react-router';
 import styled from '@emotion/styled';
 import capitalize from 'lodash/capitalize';
 
 import Access from 'sentry/components/acl/access';
 import MenuItemActionLink from 'sentry/components/actions/menuItemActionLink';
+import AsyncComponent from 'sentry/components/asyncComponent';
 import Button from 'sentry/components/button';
 import DropdownLink from 'sentry/components/dropdownLink';
 import IntegrationExternalMappingForm from 'sentry/components/integrationExternalMappingForm';
 import Pagination from 'sentry/components/pagination';
 import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
 import Tooltip from 'sentry/components/tooltip';
-import {IconAdd, IconArrow, IconEllipsis} from 'sentry/icons';
+import {IconAdd, IconArrow, IconEllipsis, IconQuestion} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import PluginIcon from 'sentry/plugins/components/pluginIcon';
 import space from 'sentry/styles/space';
-import {ExternalActorMapping, Integration, Organization} from 'sentry/types';
-import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
+import {
+  ExternalActorMapping,
+  ExternalActorMappingOrSuggestion,
+  ExternalActorSuggestion,
+  Integration,
+  Organization,
+} from 'sentry/types';
+import {getIntegrationIcon, isExternalActorMapping} from 'sentry/utils/integrationUtil';
 import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
 
-type Props = Pick<
-  IntegrationExternalMappingForm['props'],
-  | 'dataEndpoint'
-  | 'getBaseFormEndpoint'
-  | 'sentryNamesMapper'
-  | 'onResults'
-  | 'defaultOptions'
-> & {
-  organization: Organization;
-  integration: Integration;
-  mappings: ExternalActorMapping[];
-  type: 'team' | 'user';
-  onCreate: (mapping?: ExternalActorMapping) => void;
-  onDelete: (mapping: ExternalActorMapping) => void;
-  pageLinks?: string;
+type CodeOwnersAssociationMappings = {
+  [projectSlug: string]: {
+    associations: {
+      [externalName: string]: string;
+    };
+    errors: {
+      [errorKey: string]: string;
+    };
+  };
 };
 
-type State = {};
-class IntegrationExternalMappings extends Component<Props, State> {
-  renderMappingName(mapping: ExternalActorMapping, hasAccess: boolean) {
+type Props = AsyncComponent['props'] &
+  WithRouterProps &
+  Pick<
+    IntegrationExternalMappingForm['props'],
+    | 'dataEndpoint'
+    | 'getBaseFormEndpoint'
+    | 'sentryNamesMapper'
+    | 'onResults'
+    | 'defaultOptions'
+  > & {
+    organization: Organization;
+    integration: Integration;
+    mappings: ExternalActorMappingOrSuggestion[];
+    type: 'team' | 'user';
+    onCreate: (mapping?: ExternalActorMappingOrSuggestion) => void;
+    onDelete: (mapping: ExternalActorMapping) => void;
+    pageLinks?: string;
+  };
+
+type State = AsyncComponent['state'] & {
+  associationMappings: CodeOwnersAssociationMappings;
+  newlyAssociatedMappings: ExternalActorMapping[];
+};
+
+class IntegrationExternalMappings extends AsyncComponent<Props, State> {
+  getDefaultState(): State {
+    return {
+      ...super.getDefaultState(),
+      associationMappings: {},
+      newlyAssociatedMappings: [],
+    };
+  }
+
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {organization, integration} = this.props;
+    return [
+      [
+        'associationMappings',
+        `/organizations/${organization.slug}/codeowners-associations/`,
+        {query: {provider: integration.provider.key}},
+      ],
+    ];
+  }
+
+  get isFirstPage(): boolean {
+    const {cursor} = this.props.location.query;
+    return cursor ? cursor?.split(':')[1] === '0' : true;
+  }
+
+  get unassociatedMappings(): ExternalActorSuggestion[] {
+    const {type} = this.props;
+    const {associationMappings} = this.state;
+    const errorKey = `missing_external_${type}s`;
+    const unassociatedMappings = Object.values(associationMappings).reduce(
+      (map, {errors}) => {
+        return new Set<string>([...map, ...errors[errorKey]]);
+      },
+      new Set<string>()
+    );
+    return Array.from(unassociatedMappings).map(externalName => ({externalName}));
+  }
+
+  get allMappings(): ExternalActorMappingOrSuggestion[] {
+    const {mappings} = this.props;
+    if (!this.isFirstPage) {
+      return mappings;
+    }
+    const {newlyAssociatedMappings} = this.state;
+    const inlineMappings = this.unassociatedMappings.map(mapping => {
+      // If this mapping has been changed, replace it with the new version from its change's response
+      // The new version will be used in IntegrationExternalMappingForm to update the apiMethod and apiEndpoint
+      const newlyAssociatedMapping = newlyAssociatedMappings.find(
+        ({externalName}) => externalName === mapping.externalName
+      );
+
+      return newlyAssociatedMapping ?? mapping;
+    });
+    return [...inlineMappings, ...mappings];
+  }
+
+  renderMappingName(mapping: ExternalActorMappingOrSuggestion, hasAccess: boolean) {
     const {
       type,
       getBaseFormEndpoint,
@@ -47,7 +127,7 @@ class IntegrationExternalMappings extends Component<Props, State> {
       onResults,
       defaultOptions,
     } = this.props;
-    const mappingName = mapping.sentryName ?? '';
+    const mappingName = isExternalActorMapping(mapping) ? mapping.sentryName : '';
     return hasAccess ? (
       <IntegrationExternalMappingForm
         type={type}
@@ -57,6 +137,16 @@ class IntegrationExternalMappings extends Component<Props, State> {
         mapping={mapping}
         sentryNamesMapper={sentryNamesMapper}
         onResults={onResults}
+        onSubmitSuccess={(newMapping: ExternalActorMapping) => {
+          this.setState({
+            newlyAssociatedMappings: [
+              ...this.state.newlyAssociatedMappings.filter(
+                map => map.externalName !== newMapping.externalName
+              ),
+              newMapping as ExternalActorMapping,
+            ],
+          });
+        }}
         isInline
         defaultOptions={defaultOptions}
       />
@@ -65,9 +155,9 @@ class IntegrationExternalMappings extends Component<Props, State> {
     );
   }
 
-  renderMappingOptions(mapping: ExternalActorMapping, hasAccess: boolean) {
+  renderMappingOptions(mapping: ExternalActorMappingOrSuggestion, hasAccess: boolean) {
     const {type, onDelete} = this.props;
-    return (
+    return isExternalActorMapping(mapping) ? (
       <Tooltip
         title={t(
           'You must be an organization owner, manager or admin to make changes to an external user mapping.'
@@ -96,11 +186,17 @@ class IntegrationExternalMappings extends Component<Props, State> {
           </MenuItemActionLink>
         </DropdownLink>
       </Tooltip>
+    ) : (
+      <Tooltip
+        title={t(`This ${type} mapping suggestion was generated from a CODEOWNERS file`)}
+      >
+        <Button borderless size="small" icon={<IconQuestion size="sm" />} disabled />
+      </Tooltip>
     );
   }
 
-  render() {
-    const {integration, mappings, type, onCreate, pageLinks} = this.props;
+  renderBody() {
+    const {integration, type, onCreate, pageLinks} = this.props;
     return (
       <Fragment>
         <Panel>
@@ -130,7 +226,7 @@ class IntegrationExternalMappings extends Component<Props, State> {
                         icon={<IconAdd size="xs" isCircled />}
                         disabled={!hasAccess}
                       >
-                        {tct('Add [type] Mapping', {type})}
+                        <ButtonText>{tct('Add [type] Mapping', {type})}</ButtonText>
                       </AddButton>
                     </Tooltip>
                   </ButtonColumn>
@@ -139,12 +235,12 @@ class IntegrationExternalMappings extends Component<Props, State> {
             </HeaderLayout>
           </PanelHeader>
           <PanelBody>
-            {!mappings.length && (
+            {!this.allMappings.length && (
               <EmptyMessage icon={getIntegrationIcon(integration.provider.key, 'lg')}>
                 {tct('Set up External [type] Mappings.', {type: capitalize(type)})}
               </EmptyMessage>
             )}
-            {mappings.map((mapping, index) => (
+            {this.allMappings.map((mapping, index) => (
               <Access access={['org:integrations']} key={index}>
                 {({hasAccess}) => (
                   <ConfigPanelItem>
@@ -175,10 +271,15 @@ class IntegrationExternalMappings extends Component<Props, State> {
   }
 }
 
-export default IntegrationExternalMappings;
+export default withRouter(IntegrationExternalMappings);
 
 const AddButton = styled(Button)`
   text-transform: capitalize;
+  height: inherit;
+`;
+
+const ButtonText = styled('div')`
+  white-space: break-spaces;
 `;
 
 const Layout = styled('div')`
@@ -187,7 +288,7 @@ const Layout = styled('div')`
   padding: ${space(1)};
   width: 100%;
   align-items: center;
-  grid-template-columns: 2.5fr 50px 2.5fr 1fr;
+  grid-template-columns: 2.25fr 50px 2.75fr 100px;
   grid-template-areas: 'external-name arrow sentry-name button';
 `;
 
@@ -206,6 +307,7 @@ const IconEllipsisVertical = styled(IconEllipsis)`
 `;
 
 const StyledPluginIcon = styled(PluginIcon)`
+  min-width: ${p => p.size}px;
   margin-right: ${space(2)};
 `;
 

+ 10 - 0
static/app/types/integrations.tsx

@@ -31,6 +31,16 @@ export type ExternalActorMapping = {
   sentryName: string;
 };
 
+export type ExternalActorSuggestion = {
+  externalName: string;
+  userId?: string;
+  teamId?: string;
+};
+
+export type ExternalActorMappingOrSuggestion =
+  | ExternalActorMapping
+  | ExternalActorSuggestion;
+
 export type ExternalUser = {
   id: string;
   memberId: string;

+ 10 - 3
static/app/utils/integrationUtil.tsx

@@ -16,6 +16,7 @@ import {
   AppOrProviderOrPlugin,
   DocIntegration,
   ExternalActorMapping,
+  ExternalActorMappingOrSuggestion,
   Integration,
   IntegrationFeature,
   IntegrationInstallationStatus,
@@ -150,6 +151,12 @@ export function isDocIntegration(
   return integration.hasOwnProperty('isDraft');
 }
 
+export function isExternalActorMapping(
+  mapping: ExternalActorMappingOrSuggestion
+): mapping is ExternalActorMapping {
+  return mapping.hasOwnProperty('id');
+}
+
 export const getIntegrationType = (
   integration: AppOrProviderOrPlugin
 ): IntegrationType => {
@@ -234,14 +241,14 @@ export const getAlertText = (integrations?: Integration[]): string | undefined =
 /**
  * Uses the mapping and baseEndpoint to derive the details for the mappings request.
  * @param baseEndpoint Must have a trailing slash, since the id is appended for PUT requests!
- * @param mapping The mapping being sent to the endpoint
+ * @param mapping The mapping or suggestion being sent to the endpoint
  * @returns An object containing the request method (apiMethod), and final endpoint (apiEndpoint)
  */
 export const getExternalActorEndpointDetails = (
   baseEndpoint: string,
-  mapping?: ExternalActorMapping
+  mapping?: ExternalActorMappingOrSuggestion
 ): {apiMethod: 'POST' | 'PUT'; apiEndpoint: string} => {
-  const isValidMapping = !!mapping?.id;
+  const isValidMapping = mapping && isExternalActorMapping(mapping);
   return {
     apiMethod: isValidMapping ? 'PUT' : 'POST',
     apiEndpoint: isValidMapping ? `${baseEndpoint}${mapping.id}/` : baseEndpoint,

+ 9 - 3
static/app/views/organizationIntegrations/integrationExternalTeamMappings.tsx

@@ -8,7 +8,13 @@ import AsyncComponent from 'sentry/components/asyncComponent';
 import IntegrationExternalMappingForm from 'sentry/components/integrationExternalMappingForm';
 import IntegrationExternalMappings from 'sentry/components/integrationExternalMappings';
 import {t} from 'sentry/locale';
-import {ExternalActorMapping, Integration, Organization, Team} from 'sentry/types';
+import {
+  ExternalActorMapping,
+  ExternalActorMappingOrSuggestion,
+  Integration,
+  Organization,
+  Team,
+} from 'sentry/types';
 import {sentryNameToOption} from 'sentry/utils/integrationUtil';
 import withOrganization from 'sentry/utils/withOrganization';
 
@@ -105,7 +111,7 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
     return this.sentryNamesMapper(initialResults).map(sentryNameToOption);
   }
 
-  getBaseFormEndpoint(mapping?: ExternalActorMapping) {
+  getBaseFormEndpoint(mapping?: ExternalActorMappingOrSuggestion) {
     if (!mapping) {
       return '';
     }
@@ -150,7 +156,7 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
     }
   };
 
-  openModal = (mapping?: ExternalActorMapping) => {
+  openModal = (mapping?: ExternalActorMappingOrSuggestion) => {
     const {integration} = this.props;
     openModal(({Body, Header, closeModal}) => (
       <Fragment>

+ 2 - 1
static/app/views/organizationIntegrations/integrationExternalUserMappings.tsx

@@ -9,6 +9,7 @@ import IntegrationExternalMappings from 'sentry/components/integrationExternalMa
 import {t} from 'sentry/locale';
 import {
   ExternalActorMapping,
+  ExternalActorMappingOrSuggestion,
   ExternalUser,
   Integration,
   Member,
@@ -107,7 +108,7 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
       });
   }
 
-  openModal = (mapping?: ExternalActorMapping) => {
+  openModal = (mapping?: ExternalActorMappingOrSuggestion) => {
     const {integration} = this.props;
     openModal(({Body, Header, closeModal}) => (
       <Fragment>

+ 13 - 1
static/app/views/settings/organizationIntegrations/configureIntegration.tsx

@@ -30,6 +30,7 @@ import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHea
 
 type RouteParams = {
   orgId: string;
+  providerKey: string;
   integrationId: string;
 };
 type Props = RouteComponentProps<RouteParams, {}> & {
@@ -54,7 +55,18 @@ class ConfigureIntegration extends AsyncView<Props, State> {
   }
 
   componentDidMount() {
-    const {location} = this.props;
+    const {
+      location,
+      router,
+      organization,
+      params: {orgId, providerKey},
+    } = this.props;
+    // This page should not be accessible by members
+    if (!organization.access.includes('org:integrations')) {
+      router.push({
+        pathname: `/settings/${orgId}/integrations/${providerKey}/`,
+      });
+    }
     const value =
       (['codeMappings', 'userMappings', 'teamMappings'] as const).find(
         tab => tab === location.query.tab