Browse Source

feat(codeowners): Create CTA and instrument with analytics (#27972)

Objective:
CTA only shows if the organization is Early Adopter, organization is on Business plan and the project does not have any uploaded CODEOWNERS. Can dismiss for 30 days per organization per project.
NisanthanNanthakumar 3 years ago
parent
commit
14e20a26c2

+ 1 - 0
src/sentry/utils/prompts.py

@@ -11,6 +11,7 @@ DEFAULT_PROMPTS = {
     "stacktrace_link": {"required_fields": ["organization_id", "project_id"]},
     "distributed_tracing": {"required_fields": ["organization_id", "project_id"]},
     "quick_trace_missing": {"required_fields": ["organization_id", "project_id"]},
+    "code_owners": {"required_fields": ["organization_id", "project_id"]},
 }
 
 

+ 118 - 8
static/app/components/group/suggestedOwners/ownershipRules.tsx

@@ -3,13 +3,18 @@ import {ClassNames} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {openCreateOwnershipRule} from 'app/actionCreators/modal';
+import Access from 'app/components/acl/access';
 import GuideAnchor from 'app/components/assistant/guideAnchor';
 import Button from 'app/components/button';
+import ButtonBar from 'app/components/buttonBar';
+import FeatureBadge from 'app/components/featureBadge';
 import Hovercard from 'app/components/hovercard';
-import {IconQuestion} from 'app/icons';
+import {Panel} from 'app/components/panels';
+import {IconClose, IconQuestion} from 'app/icons';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
-import {Organization, Project} from 'app/types';
+import {CodeOwner, Organization, Project} from 'app/types';
+import {trackAdvancedAnalyticsEvent} from 'app/utils/advancedAnalytics';
 
 import SidebarSection from '../sidebarSection';
 
@@ -17,12 +22,84 @@ type Props = {
   project: Project;
   organization: Organization;
   issueId: string;
+  codeowners: CodeOwner[];
+  handleCTAClose: () => void;
+  isDismissed: boolean;
 };
 
-const OwnershipRules = ({project, organization, issueId}: Props) => {
+const OwnershipRules = ({
+  project,
+  organization,
+  issueId,
+  codeowners,
+  isDismissed,
+  handleCTAClose,
+}: Props) => {
   const handleOpenCreateOwnershipRule = () => {
     openCreateOwnershipRule({project, organization, issueId});
   };
+  const showCTA =
+    organization.features.includes('integrations-codeowners') &&
+    !codeowners.length &&
+    !isDismissed;
+
+  const createRuleButton = (
+    <Access access={['project:write']}>
+      <GuideAnchor target="owners" position="bottom" offset={space(3)}>
+        <Button onClick={handleOpenCreateOwnershipRule} size="small">
+          {t('Create Ownership Rule')}
+        </Button>
+      </GuideAnchor>
+    </Access>
+  );
+
+  const codeOwnersCTA = (
+    <Container dashedBorder>
+      <HeaderContainer>
+        <Header>{t('Codeowners sync')}</Header> <FeatureBadge type="beta" noTooltip />
+        <DismissButton
+          icon={<IconClose size="xs" />}
+          priority="link"
+          onClick={() => handleCTAClose()}
+        />
+      </HeaderContainer>
+      <Content>
+        {t(
+          'Import GitHub or GitLab CODEOWNERS files to automatically assign issues to the right people.'
+        )}
+      </Content>
+      <ButtonBar gap={1}>
+        <SetupButton
+          size="small"
+          priority="primary"
+          href={`/settings/${organization.slug}/projects/${project.slug}/ownership/`}
+          onClick={() =>
+            trackAdvancedAnalyticsEvent('integrations.code_owners_cta_setup_clicked', {
+              view: 'stacktrace_issue_details',
+              project_id: project.id,
+              organization,
+            })
+          }
+        >
+          {t('Set It Up')}
+        </SetupButton>
+        <Button
+          size="small"
+          external
+          href="https://docs.sentry.io/product/issues/issue-owners/#code-owners"
+          onClick={() =>
+            trackAdvancedAnalyticsEvent('integrations.code_owners_cta_docs_clicked', {
+              view: 'stacktrace_issue_details',
+              project_id: project.id,
+              organization,
+            })
+          }
+        >
+          {t('Read Docs')}
+        </Button>
+      </ButtonBar>
+    </Container>
+  );
 
   return (
     <SidebarSection
@@ -59,11 +136,7 @@ const OwnershipRules = ({project, organization, issueId}: Props) => {
         </Fragment>
       }
     >
-      <GuideAnchor target="owners" position="bottom" offset={space(3)}>
-        <Button onClick={handleOpenCreateOwnershipRule} size="small">
-          {t('Create Ownership Rule')}
-        </Button>
-      </GuideAnchor>
+      {showCTA ? codeOwnersCTA : createRuleButton}
     </SidebarSection>
   );
 };
@@ -78,3 +151,40 @@ const HelpfulBody = styled('div')`
   padding: ${space(1)};
   text-align: center;
 `;
+
+const Container = styled(Panel)`
+  background: none;
+  display: flex;
+  flex-direction: column;
+  padding: ${space(2)};
+`;
+
+const HeaderContainer = styled('div')`
+  display: grid;
+  grid-template-columns: max-content max-content 1fr;
+  align-items: flex-start;
+`;
+
+const Header = styled('h4')`
+  margin-bottom: ${space(1)};
+  text-transform: uppercase;
+  font-weight: bold;
+  color: ${p => p.theme.gray400};
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+const Content = styled('span')`
+  color: ${p => p.theme.subText};
+  margin-bottom: ${space(2)};
+`;
+
+const SetupButton = styled(Button)`
+  &:focus {
+    color: ${p => p.theme.white};
+  }
+`;
+
+const DismissButton = styled(Button)`
+  justify-self: flex-end;
+  color: ${p => p.theme.gray400};
+`;

+ 81 - 9
static/app/components/group/suggestedOwners/suggestedOwners.tsx

@@ -1,10 +1,12 @@
 import * as React from 'react';
 
 import {assignToActor, assignToUser} from 'app/actionCreators/group';
+import {promptsCheck, promptsUpdate} from 'app/actionCreators/prompts';
 import {Client} from 'app/api';
-import Access from 'app/components/acl/access';
-import {Actor, Committer, Group, Organization, Project} from 'app/types';
+import {Actor, CodeOwner, Committer, Group, Organization, Project} from 'app/types';
 import {Event} from 'app/types/event';
+import {trackAdvancedAnalyticsEvent} from 'app/utils/advancedAnalytics';
+import {promptIsDismissed} from 'app/utils/promptIsDismissed';
 import withApi from 'app/utils/withApi';
 import withCommitters from 'app/utils/withCommitters';
 import withOrganization from 'app/utils/withOrganization';
@@ -27,12 +29,16 @@ type Props = {
 type State = {
   rules: Rules;
   owners: Array<Actor>;
+  codeowners: CodeOwner[];
+  isDismissed: boolean;
 };
 
 class SuggestedOwners extends React.Component<Props, State> {
   state: State = {
     rules: null,
     owners: [],
+    codeowners: [],
+    isDismissed: false,
   };
 
   componentDidMount() {
@@ -56,8 +62,72 @@ class SuggestedOwners extends React.Component<Props, State> {
 
   async fetchData(event: Event) {
     this.fetchOwners(event.id);
+    this.fetchCodeOwners();
+    this.checkCodeOwnersPrompt();
   }
 
+  async checkCodeOwnersPrompt() {
+    const {api, organization, project} = this.props;
+
+    // check our prompt backend
+    const promptData = await promptsCheck(api, {
+      organizationId: organization.id,
+      projectId: project.id,
+      feature: 'code_owners',
+    });
+    const isDismissed = promptIsDismissed(promptData, 30);
+    this.setState({isDismissed}, () => {
+      if (!isDismissed) {
+        // now record the results
+        trackAdvancedAnalyticsEvent(
+          'integrations.show_code_owners_prompt',
+          {
+            view: 'stacktrace_issue_details',
+            project_id: project.id,
+            organization,
+          },
+          {startSession: true}
+        );
+      }
+    });
+  }
+
+  handleCTAClose = () => {
+    const {api, organization, project} = this.props;
+
+    promptsUpdate(api, {
+      organizationId: organization.id,
+      projectId: project.id,
+      feature: 'code_owners',
+      status: 'dismissed',
+    });
+
+    this.setState({isDismissed: true}, () =>
+      trackAdvancedAnalyticsEvent('integrations.dismissed_code_owners_prompt', {
+        view: 'stacktrace_issue_details',
+        project_id: project.id,
+        organization,
+      })
+    );
+  };
+
+  fetchCodeOwners = async () => {
+    const {api, project, organization} = this.props;
+
+    try {
+      const data = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/codeowners/`
+      );
+      this.setState({
+        codeowners: data,
+      });
+    } catch {
+      this.setState({
+        codeowners: [],
+      });
+    }
+  };
+
   fetchOwners = async (eventId: Event['id']) => {
     const {api, project, organization} = this.props;
 
@@ -155,6 +225,7 @@ class SuggestedOwners extends React.Component<Props, State> {
 
   render() {
     const {organization, project, group} = this.props;
+    const {codeowners, isDismissed} = this.state;
     const owners = this.getOwnerList();
 
     return (
@@ -162,13 +233,14 @@ class SuggestedOwners extends React.Component<Props, State> {
         {owners.length > 0 && (
           <SuggestedAssignees owners={owners} onAssign={this.handleAssign} />
         )}
-        <Access access={['project:write']}>
-          <OwnershipRules
-            issueId={group.id}
-            project={project}
-            organization={organization}
-          />
-        </Access>
+        <OwnershipRules
+          issueId={group.id}
+          project={project}
+          organization={organization}
+          codeowners={codeowners}
+          isDismissed={isDismissed}
+          handleCTAClose={this.handleCTAClose}
+        />
       </React.Fragment>
     );
   }

+ 14 - 0
static/app/utils/integrationEvents.tsx

@@ -56,6 +56,9 @@ type IntegrationInstalltionInputValueChangeEventParams = {
   field_name: string;
 } & SingleIntegrationEventParams;
 
+type IntegrationCodeOwnersEventParams = {
+  project_id: string;
+} & View;
 // define the event key to payload mappings
 export type IntegrationEventParameters = {
   'integrations.upgrade_plan_modal_opened': SingleIntegrationEventParams;
@@ -90,6 +93,10 @@ export type IntegrationEventParameters = {
   'integrations.serverless_function_action': IntegrationServerlessFunctionActionParams;
   'integrations.cloudformation_link_clicked': SingleIntegrationEventParams;
   'integrations.switch_manual_sdk_setup': SingleIntegrationEventParams;
+  'integrations.code_owners_cta_setup_clicked': IntegrationCodeOwnersEventParams;
+  'integrations.code_owners_cta_docs_clicked': IntegrationCodeOwnersEventParams;
+  'integrations.show_code_owners_prompt': IntegrationCodeOwnersEventParams;
+  'integrations.dismissed_code_owners_prompt': IntegrationCodeOwnersEventParams;
 };
 
 export type IntegrationAnalyticsKey = keyof IntegrationEventParameters;
@@ -134,4 +141,11 @@ export const integrationEventMap: Record<IntegrationAnalyticsKey, string> = {
   'integrations.serverless_function_action': 'Integrations: Serverless Function Action',
   'integrations.cloudformation_link_clicked': 'Integrations: CloudFormation Link Clicked',
   'integrations.switch_manual_sdk_setup': 'Integrations: Switch Manual SDK Setup',
+  'integrations.code_owners_cta_setup_clicked':
+    'Integrations: Code Owners CTA Setup Clicked',
+  'integrations.code_owners_cta_docs_clicked':
+    'Integrations: Code Owners CTA Setup Clicked',
+  'integrations.show_code_owners_prompt': 'Integrations: Show Code Owners Prompt',
+  'integrations.dismissed_code_owners_prompt':
+    'Integrations: Dismissed Code Owners Prompt',
 };

+ 9 - 0
tests/js/spec/components/group/sidebar.spec.jsx

@@ -49,6 +49,15 @@ describe('GroupSidebar', function () {
       body: [],
     });
 
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/codeowners/`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: `/prompts-activity/`,
+      body: {},
+    });
+
     tagsMock = MockApiClient.addMockResponse({
       url: '/issues/1/tags/',
       body: TestStubs.Tags(),

+ 8 - 0
tests/js/spec/components/group/suggestedOwners.spec.jsx

@@ -22,6 +22,14 @@ describe('SuggestedOwners', function () {
 
   beforeEach(function () {
     MemberListStore.loadInitialData([user, TestStubs.CommitAuthor()]);
+    Client.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/codeowners/`,
+      body: [],
+    });
+    Client.addMockResponse({
+      url: `/prompts-activity/`,
+      body: {},
+    });
   });
 
   afterEach(function () {

+ 4 - 0
tests/js/spec/views/organizationGroupDetails/groupEventDetails.spec.jsx

@@ -77,6 +77,10 @@ describe('groupEventDetails', () => {
       url: `/projects/${org.slug}/${project.slug}/events/${event.id}/grouping-info/`,
       body: {},
     });
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/codeowners/`,
+      body: [],
+    });
   };
 
   beforeEach(() => {