Browse Source

feat(codeowners): CodeOwners header CTA hook (#28602)

Objective:
Hooks for the CodeOwners CTA in getsentry. Also created a Request to Add Codeowners button for all members.
NisanthanNanthakumar 3 years ago
parent
commit
ecd506dc63

+ 19 - 0
src/sentry/api/bases/project_request_change.py

@@ -0,0 +1,19 @@
+from sentry.api.bases import ProjectEndpoint, ProjectPermission
+
+
+class ProjectRequestChangeEndpointPermission(ProjectPermission):
+    # just requesting so read permission is enough
+    scope_map = {
+        "POST": ["org:read"],
+    }
+
+    def is_member_disabled_from_limit(self, request, organization):
+        # disabled members need to be able to make requests
+        return False
+
+
+# This is a base endpoint which can be used when a member
+# requests a change for their org which send an email to the appropriate
+# person
+class ProjectRequestChangeEndpoint(ProjectEndpoint):
+    permission_classes = (ProjectRequestChangeEndpointPermission,)

+ 58 - 0
src/sentry/api/endpoints/project_codeowners_request.py

@@ -0,0 +1,58 @@
+import logging
+
+from django.utils.translation import ugettext as _
+
+from sentry import roles
+from sentry.api.bases.project_request_change import ProjectRequestChangeEndpoint
+from sentry.models import OrganizationMember
+from sentry.utils.email import MessageBuilder
+from sentry.utils.http import absolute_uri
+
+logger = logging.getLogger(__name__)
+
+
+def get_codeowners_request_builder_args(project, recipient, requester_name):
+    return {
+        "subject": _("A team member is asking to set up Sentry's Code Owners"),
+        "type": "organization.codeowners-request",
+        "context": {
+            "requester_name": requester_name,
+            "recipient_name": recipient.get_display_name(),
+            "organization_name": project.organization.name,
+            "project_name": project.name,
+            "codeowners_url": absolute_uri(
+                f"/settings/{project.organization.slug}/projects/{project.slug}/ownership/?referrer=codeowners-email"
+            ),
+        },
+        "template": "sentry/emails/requests/codeowners.txt",
+        "html_template": "sentry/emails/requests/codeowners.html",
+    }
+
+
+class ProjectCodeOwnersRequestEndpoint(ProjectRequestChangeEndpoint):
+    def post(self, request, project):
+        """
+        Request to Add CODEOWNERS to a Project
+        ````````````````````````````````````
+        :pparam string organization_slug: the slug of the organization the member will belong to
+        :pparam string project_slug: the slug of the project
+        :auth: required
+        """
+
+        requester_name = request.user.get_display_name()
+        integrations_roles = [r.id for r in roles.get_all() if r.has_scope("org:integrations")]
+        recipients = OrganizationMember.objects.get_contactable_members_for_org(
+            project.organization.id
+        ).filter(role__in=integrations_roles)
+
+        for recipient in recipients:
+            msg = MessageBuilder(
+                **get_codeowners_request_builder_args(project, recipient, requester_name)
+            )
+            email = recipient.get_email()
+            logger.info(
+                "send_email", extra={"organization_id": project.organization.id, "email": email}
+            )
+            msg.send_async([email])
+
+        return self.respond(status=202)

+ 6 - 0
src/sentry/api/urls.py

@@ -286,6 +286,7 @@ from .endpoints.project_app_store_connect_credentials import (
 from .endpoints.project_avatar import ProjectAvatarEndpoint
 from .endpoints.project_codeowners import ProjectCodeOwnersEndpoint
 from .endpoints.project_codeowners_details import ProjectCodeOwnersDetailsEndpoint
+from .endpoints.project_codeowners_request import ProjectCodeOwnersRequestEndpoint
 from .endpoints.project_create_sample import ProjectCreateSampleEndpoint
 from .endpoints.project_create_sample_transaction import ProjectCreateSampleTransactionEndpoint
 from .endpoints.project_details import ProjectDetailsEndpoint
@@ -1888,6 +1889,11 @@ urlpatterns = [
                     ProjectCodeOwnersDetailsEndpoint.as_view(),
                     name="sentry-api-0-project-codeowners-details",
                 ),
+                url(
+                    r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/codeowners-request/$",
+                    ProjectCodeOwnersRequestEndpoint.as_view(),
+                    name="getsentry-api-0-project-codeowners-request",
+                ),
                 url(
                     r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/transaction-threshold/configure/$",
                     ProjectTransactionThresholdEndpoint.as_view(),

+ 32 - 0
src/sentry/templates/sentry/emails/requests/codeowners.html

@@ -0,0 +1,32 @@
+{% extends "sentry/emails/base.html" %}
+
+{% load i18n %}
+
+
+{% block main %}
+
+<h2>{% trans "Request to Setup Code Owners" %}</h2>
+
+<p>
+  {% blocktrans %}Hey {{ recipient_name }},{% endblocktrans %}
+</p>
+<p>
+  {% blocktrans %}
+  Heads up, <strong>{{ requester_name }}</strong> requested to setup <a href="https://docs.sentry.io/product/issues/issue-owners/#code-owners">Code Owners</a> for the <strong>{{ project_name }}</strong> project in <strong>{{ organization_name }}</strong>. They <i>did</i> ask nicely, if that helps.
+  {% endblocktrans %}
+</p>
+
+<p>
+  <a href="{{ codeowners_url }}" class="btn">{% trans "Setup Code Owners Now" %}</a>
+</p>
+
+<p>
+  {% trans "To Better Software," %}
+  <br>
+  {% trans "The Sentry Team" %}
+</p>
+
+<p class="via">
+  {% trans "You are receiving this email because you're listed as an organization Admin, Manager or Owner." %}
+</p>
+{% endblock %}

+ 16 - 0
src/sentry/templates/sentry/emails/requests/codeowners.txt

@@ -0,0 +1,16 @@
+Request to Setup Code Owners
+
+{% block main %}
+Hey {{ recipient_name }},
+
+Heads up, {{ requester_name }} requested to setup Code Owners for the {{ project_name }} project in {{ organization_name }}. They did ask nicely, if that helps.
+
+    Learn More: https://docs.sentry.io/product/issues/issue-owners/#code-owners
+
+    Setup Now: {{ codeowners_url }}
+
+To Better Software,
+The Sentry Team
+
+You are receiving this email because you're listed as an organization Admin, Manager or Owner.
+{% endblock %}

+ 2 - 0
src/sentry/web/debug_urls.py

@@ -9,6 +9,7 @@ from sentry.web.frontend.debug.debug_assigned_email import (
     DebugSelfAssignedTeamEmailView,
 )
 from sentry.web.frontend.debug.debug_chart_renderer import DebugChartRendererView
+from sentry.web.frontend.debug.debug_codeowners_request_mail import DebugCodeOwnersRequestView
 from sentry.web.frontend.debug.debug_error_embed import DebugErrorPageEmbedView
 from sentry.web.frontend.debug.debug_incident_activity_email import DebugIncidentActivityEmailView
 from sentry.web.frontend.debug.debug_incident_trigger_email import DebugIncidentTriggerEmailView
@@ -88,6 +89,7 @@ urlpatterns = [
     url(r"^debug/mail/access-approved/$", sentry.web.frontend.debug.mail.access_approved),
     url(r"^debug/mail/invitation/$", sentry.web.frontend.debug.mail.invitation),
     url(r"^debug/mail/invalid-identity/$", DebugInvalidIdentityEmailView.as_view()),
+    url(r"^debug/mail/codeowners-request/$", DebugCodeOwnersRequestView.as_view()),
     url(r"^debug/mail/confirm-email/$", sentry.web.frontend.debug.mail.confirm_email),
     url(r"^debug/mail/recover-account/$", sentry.web.frontend.debug.mail.recover_account),
     url(r"^debug/mail/unable-to-delete-repo/$", DebugUnableToDeleteRepository.as_view()),

+ 22 - 0
src/sentry/web/frontend/debug/debug_codeowners_request_mail.py

@@ -0,0 +1,22 @@
+from django.views.generic import View
+
+from sentry.api.endpoints.project_codeowners_request import get_codeowners_request_builder_args
+from sentry.models import Organization, OrganizationMember, Project, User
+from sentry.web.frontend.debug.mail import MailPreviewAdapter
+from sentry.web.helpers import render_to_response
+
+
+class DebugCodeOwnersRequestView(View):
+    def get(self, request):
+        requester_name = request.GET.get("requester_name", "Requester")
+        recipient_name = request.GET.get("recipient_name", "Recepient")
+
+        org = Organization(id=1, slug="petal", name="Petal")
+        project = Project(id=1, slug="nodejs", name="Node.js", organization=org)
+        user = User(name=recipient_name)
+        member = OrganizationMember(organization=org, user=user, role="admin")
+        preview = MailPreviewAdapter(
+            **get_codeowners_request_builder_args(project, member, requester_name)
+        )
+
+        return render_to_response("sentry/debug/mail/preview.html", {"preview": preview})

+ 1 - 0
static/app/stores/hookStore.tsx

@@ -22,6 +22,7 @@ const validHookNames = new Set<HookName>([
   'component:header-selector-items',
   'component:global-notifications',
   'component:member-list-header',
+  'component:codeowners-header',
   'component:dashboards-header',
   'feature-disabled:alerts-page',
   'feature-disabled:alert-wizard-performance',

+ 5 - 1
static/app/types/hooks.tsx

@@ -70,7 +70,10 @@ type DisabledAppStoreConnectItem = {
 };
 type DisabledMemberTooltipProps = {children: React.ReactNode};
 type DashboardHeadersProps = {organization: Organization};
-
+type CodeOwnersHeaderProps = {
+  addCodeOwner: () => void;
+  handleRequest: () => void;
+};
 /**
  * Component wrapping hooks
  */
@@ -80,6 +83,7 @@ export type ComponentHooks = {
   'component:global-notifications': () => React.ComponentType<GlobalNotificationProps>;
   'component:disabled-member': () => React.ComponentType<DisabledMemberViewProps>;
   'component:member-list-header': () => React.ComponentType<MemberListHeaderProps>;
+  'component:codeowners-header': () => React.ComponentType<CodeOwnersHeaderProps>;
   'component:disabled-member-tooltip': () => React.ComponentType<DisabledMemberTooltipProps>;
   'component:disabled-app-store-connect-item': () => React.ComponentType<DisabledAppStoreConnectItem>;
   'component:dashboards-header': () => React.ComponentType<DashboardHeadersProps>;

+ 47 - 2
static/app/views/settings/project/projectOwnership/index.tsx

@@ -1,12 +1,19 @@
 import {Fragment} from 'react';
 import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
 
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'app/actionCreators/indicator';
 import {openEditOwnershipRules, openModal} from 'app/actionCreators/modal';
 import Access from 'app/components/acl/access';
 import Feature from 'app/components/acl/feature';
 import Alert from 'app/components/alert';
 import Button from 'app/components/button';
+import HookOrDefault from 'app/components/hookOrDefault';
 import ExternalLink from 'app/components/links/externalLink';
 import {IconWarning} from 'app/icons';
 import {t, tct} from 'app/locale';
@@ -41,6 +48,11 @@ type State = {
   integrations: Integration[];
 } & AsyncView['state'];
 
+const CodeOwnersHeader = HookOrDefault({
+  hookName: 'component:codeowners-header',
+  defaultComponent: () => <Fragment />,
+});
+
 class ProjectOwnership extends AsyncView<Props, State> {
   getTitle() {
     const {project} = this.props;
@@ -135,6 +147,25 @@ tags.sku_class:enterprise #enterprise`;
     });
   };
 
+  handleAddCodeOwnerRequest = async () => {
+    const {organization, project} = this.props;
+    try {
+      addLoadingMessage(t('Requesting\u2026'));
+      await this.api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/codeowners-request/`,
+        {
+          method: 'POST',
+          data: {},
+        }
+      );
+
+      addSuccessMessage(t('Request Sent'));
+    } catch (err) {
+      addErrorMessage(t('Unable to send request'));
+      Sentry.captureException(err);
+    }
+  };
+
   renderCodeOwnerErrors = () => {
     const {project, organization} = this.props;
     const {codeowners} = this.state;
@@ -273,9 +304,9 @@ tags.sku_class:enterprise #enterprise`;
                 {t('View Issues')}
               </Button>
               <Feature features={['integrations-codeowners']}>
-                <Access access={['project:write']}>
+                <Access access={['org:integrations']}>
                   {({hasAccess}) =>
-                    hasAccess && (
+                    hasAccess ? (
                       <CodeOwnerButton
                         onClick={this.handleAddCodeOwner}
                         size="small"
@@ -284,6 +315,15 @@ tags.sku_class:enterprise #enterprise`;
                       >
                         {t('Add CODEOWNERS File')}
                       </CodeOwnerButton>
+                    ) : (
+                      <CodeOwnerButton
+                        onClick={this.handleAddCodeOwnerRequest}
+                        size="small"
+                        priority="primary"
+                        data-test-id="add-codeowner-request-button"
+                      >
+                        {t('Request to Add CODEOWNERS File')}
+                      </CodeOwnerButton>
                     )
                   }
                 </Access>
@@ -292,6 +332,11 @@ tags.sku_class:enterprise #enterprise`;
           }
         />
         <IssueOwnerDetails>{this.getDetail()}</IssueOwnerDetails>
+        <CodeOwnersHeader
+          addCodeOwner={this.handleAddCodeOwner}
+          handleRequest={this.handleAddCodeOwnerRequest}
+        />
+
         <PermissionAlert />
         <FeedbackAlert />
         {this.renderCodeOwnerErrors()}

Some files were not shown because too many files changed in this diff