Просмотр исходного кода

types(python): Slack Integration Types (#28236)

Marcos Gaeta 3 лет назад
Родитель
Сommit
ee9f8c460e

+ 4 - 0
mypy.ini

@@ -22,8 +22,11 @@ files = src/sentry/api/bases/external_actor.py,
         src/sentry/grouping/strategies/base.py,
         src/sentry/grouping/strategies/message.py,
         src/sentry/grouping/result.py,
+        src/sentry/integrations/base.py,
+        src/sentry/integrations/slack/*.py,
         src/sentry/integrations/slack/message_builder/**/*.py,
         src/sentry/integrations/slack/requests/*.py,
+        src/sentry/integrations/slack/unfurl/*.py,
         src/sentry/integrations/slack/util/*.py,
         src/sentry/integrations/slack/views/*.py,
         src/sentry/killswitches.py,
@@ -33,6 +36,7 @@ files = src/sentry/api/bases/external_actor.py,
         src/sentry/models/projectoption.py,
         src/sentry/models/useroption.py,
         src/sentry/notifications/**/*.py,
+        src/sentry/shared_integrations/constants.py,
         src/sentry/snuba/outcomes.py,
         src/sentry/snuba/query_subscription_consumer.py,
         src/sentry/tasks/app_store_connect.py,

+ 67 - 44
src/sentry/integrations/base.py

@@ -10,9 +10,20 @@ import logging
 import sys
 from collections import namedtuple
 from enum import Enum
+from typing import Any, Dict, FrozenSet, Mapping, Optional, Sequence
+from urllib.request import Request
 
+from django.views import View
+
+from sentry.db.models.manager import M
 from sentry.exceptions import InvalidIdentity
-from sentry.models import AuditLogEntryEvent, Identity, OrganizationIntegration
+from sentry.models import (
+    AuditLogEntryEvent,
+    Identity,
+    Integration,
+    Organization,
+    OrganizationIntegration,
+)
 from sentry.pipeline import PipelineProvider
 from sentry.shared_integrations.constants import (
     ERR_INTERNAL,
@@ -52,9 +63,9 @@ IntegrationMetadata = namedtuple(
 )
 
 
-class IntegrationMetadata(IntegrationMetadata):
+class IntegrationMetadata(IntegrationMetadata):  # type: ignore
     @staticmethod
-    def feature_flag_name(f):
+    def feature_flag_name(f: Optional[str]) -> Optional[str]:
         """
         FeatureDescriptions are set using the IntegrationFeatures constants,
         however we expose them here as mappings to organization feature flags, thus
@@ -62,8 +73,9 @@ class IntegrationMetadata(IntegrationMetadata):
         """
         if f is not None:
             return f"integrations-{f}"
+        return None
 
-    def _asdict(self):
+    def _asdict(self) -> Dict[str, Sequence[Any]]:
         metadata = super()._asdict()
         metadata["features"] = [
             {
@@ -103,7 +115,7 @@ class IntegrationFeatures(Enum):
     DEPLOYMENT = "deployment"
 
 
-class IntegrationProvider(PipelineProvider):
+class IntegrationProvider(PipelineProvider):  # type: ignore
     """
     An integration provider describes a third party that can be registered within Sentry.
 
@@ -120,26 +132,26 @@ class IntegrationProvider(PipelineProvider):
     # a unique identifier (e.g. 'slack').
     # Used to lookup sibling classes and the ``key`` used when creating
     # Integration objects.
-    key = None
+    key: Optional[str] = None
 
     # a unique identifier to use when creating the ``Integration`` object.
     # Only needed when you want to create the above object with something other
     # than ``key``. See: VstsExtensionIntegrationProvider.
-    _integration_key = None
+    _integration_key: Optional[str] = None
 
     # Whether this integration should show up in the list on the Organization
     # Integrations page.
     visible = True
 
     # a human readable name (e.g. 'Slack')
-    name = None
+    name: Optional[str] = None
 
     # an IntegrationMetadata object, used to provide extra details in the
     # configuration interface of the integration.
-    metadata = None
+    metadata: Optional[IntegrationMetadata] = None
 
     # an Integration class that will manage the functionality once installed
-    integration_cls = None
+    integration_cls: Optional[Any] = None
 
     # configuration for the setup dialog
     setup_dialog_config = {"width": 600, "height": 600}
@@ -157,29 +169,38 @@ class IntegrationProvider(PipelineProvider):
     needs_default_identity = False
 
     # can be any number of IntegrationFeatures
-    features = frozenset()
+    features: FrozenSet[IntegrationFeatures] = frozenset()
 
     # if this is hidden without the feature flag
     requires_feature_flag = False
 
     @classmethod
-    def get_installation(cls, model, organization_id, **kwargs):
+    def get_installation(cls, model: M, organization_id: int, **kwargs: Any) -> Any:
         if cls.integration_cls is None:
             raise NotImplementedError
 
         return cls.integration_cls(model, organization_id, **kwargs)
 
     @property
-    def integration_key(self):
+    def integration_key(self) -> Optional[str]:
         return self._integration_key or self.key
 
-    def get_logger(self):
+    def get_logger(self) -> logging.Logger:
         return logging.getLogger(f"sentry.integration.{self.key}")
 
-    def post_install(self, integration, organization, extra=None):
+    def post_install(
+        self, integration: Integration, organization: Organization, extra: Optional[Any] = None
+    ) -> None:
         pass
 
-    def create_audit_log_entry(self, integration, organization, request, action, extra=None):
+    def create_audit_log_entry(
+        self,
+        integration: Integration,
+        organization: Organization,
+        request: Request,
+        action: str,
+        extra: Optional[Any] = None,
+    ) -> None:
         """
         Creates an audit log entry for the newly installed integration.
         """
@@ -192,7 +213,7 @@ class IntegrationProvider(PipelineProvider):
                 data={"provider": integration.provider, "name": integration.name},
             )
 
-    def get_pipeline_views(self):
+    def get_pipeline_views(self) -> Sequence[View]:
         """
         Return a list of ``View`` instances describing this integration's
         configuration pipeline.
@@ -202,7 +223,7 @@ class IntegrationProvider(PipelineProvider):
         """
         raise NotImplementedError
 
-    def build_integration(self, state):
+    def build_integration(self, state: Mapping[str, Any]) -> Mapping[str, Any]:
         """
         Given state captured during the setup pipeline, return a dictionary
         of configuration and metadata to store with this integration.
@@ -221,7 +242,7 @@ class IntegrationProvider(PipelineProvider):
         >>>     return {
         >>>         'external_id': state['id'],
         >>>         'name': state['name'],
-        >>>         'metadata': {url': state['url']},
+        >>>         'metadata': {'url': state['url']},
         >>>     }
 
         This can return the 'expect_exists' flag, and this method  will expect
@@ -237,7 +258,7 @@ class IntegrationProvider(PipelineProvider):
         """
         raise NotImplementedError
 
-    def setup(self):
+    def setup(self) -> None:
         """
         Executed once Sentry has been initialized at runtime.
 
@@ -245,7 +266,7 @@ class IntegrationProvider(PipelineProvider):
         >>>     bindings.add('repository.provider', GitHubRepositoryProvider, key='github')
         """
 
-    def has_feature(self, feature):
+    def has_feature(self, feature: IntegrationFeatures) -> bool:
         return feature in self.features
 
 
@@ -257,20 +278,20 @@ class IntegrationInstallation:
 
     logger = logging.getLogger("sentry.integrations")
 
-    def __init__(self, model, organization_id):
+    def __init__(self, model: M, organization_id: int) -> None:
         self.model = model
         self.organization_id = organization_id
         self._org_integration = None
 
     @property
-    def org_integration(self):
+    def org_integration(self) -> OrganizationIntegration:
         if self._org_integration is None:
             self._org_integration = OrganizationIntegration.objects.get(
                 organization_id=self.organization_id, integration_id=self.model.id
             )
         return self._org_integration
 
-    def get_organization_config(self):
+    def get_organization_config(self) -> Sequence[Any]:
         """
         Returns a list of JSONForm configuration object descriptors used to
         configure the integration per-organization. This simply represents the
@@ -280,7 +301,7 @@ class IntegrationInstallation:
         """
         return []
 
-    def update_organization_config(self, data):
+    def update_organization_config(self, data: Mapping[str, Any]) -> None:
         """
         Update the configuration field for an organization integration.
         """
@@ -288,28 +309,26 @@ class IntegrationInstallation:
         config.update(data)
         self.org_integration.update(config=config)
 
-    def get_config_data(self):
-        return self.org_integration.config
+    def get_config_data(self) -> Mapping[str, str]:
+        # Explicitly typing to satisfy mypy.
+        config_data: Mapping[str, str] = self.org_integration.config
+        return config_data
 
-    def get_dynamic_display_information(self):
+    def get_dynamic_display_information(self) -> Optional[Mapping[str, Any]]:
         return None
 
-    def get_client(self):
+    def get_client(self) -> Any:
         # Return the api client for a given provider
         raise NotImplementedError
 
-    def get_default_identity(self):
-        """
-        For Integrations that rely solely on user auth for authentication
-        """
-
-        identity = Identity.objects.get(id=self.org_integration.default_auth_id)
-        return identity
+    def get_default_identity(self) -> Identity:
+        """For Integrations that rely solely on user auth for authentication."""
+        return Identity.objects.get(id=self.org_integration.default_auth_id)
 
-    def error_message_from_json(self, data):
+    def error_message_from_json(self, data: Mapping[str, Any]) -> Any:
         return data.get("message", "unknown error")
 
-    def error_fields_from_json(self, data):
+    def error_fields_from_json(self, data: Mapping[str, Any]) -> Optional[Any]:
         """
         If we can determine error fields from the response JSON this should
         format and return them, allowing an IntegrationFormError to be raised.
@@ -319,11 +338,13 @@ class IntegrationInstallation:
         """
         return None
 
-    def message_from_error(self, exc):
+    def message_from_error(self, exc: ApiError) -> str:
         if isinstance(exc, ApiUnauthorized):
             return ERR_UNAUTHORIZED
         elif isinstance(exc, ApiHostError):
-            return exc.text
+            # Explicitly typing to satisfy mypy.
+            message: str = exc.text
+            return message
         elif isinstance(exc, UnsupportedResponseType):
             return ERR_UNSUPPORTED_RESPONSE_TYPE.format(content_type=exc.content_type)
         elif isinstance(exc, ApiError):
@@ -335,7 +356,7 @@ class IntegrationInstallation:
         else:
             return ERR_INTERNAL
 
-    def raise_error(self, exc, identity=None):
+    def raise_error(self, exc: ApiError, identity: Optional[Identity] = None) -> None:
         if isinstance(exc, ApiUnauthorized):
             raise InvalidIdentity(self.message_from_error(exc), identity=identity).with_traceback(
                 sys.exc_info()[2]
@@ -354,10 +375,12 @@ class IntegrationInstallation:
             raise IntegrationError(self.message_from_error(exc)).with_traceback(sys.exc_info()[2])
 
     @property
-    def metadata(self):
-        return self.model.metadata
+    def metadata(self) -> IntegrationMetadata:
+        # Explicitly typing to satisfy mypy.
+        _metadata: IntegrationMetadata = self.model.metadata
+        return _metadata
 
-    def uninstall(self):
+    def uninstall(self) -> None:
         """
         For integrations that need additional steps for uninstalling
         that are not covered in the `delete_organization_integration`

+ 2 - 1
src/sentry/integrations/slack/__init__.py

@@ -3,6 +3,7 @@ from sentry.utils.imports import import_submodules
 
 from .notify_action import SlackNotifyServiceAction
 
-import_submodules(globals(), __name__, __path__)
+path = __path__  # type: ignore
+import_submodules(globals(), __name__, path)
 
 rules.add(SlackNotifyServiceAction)

+ 3 - 3
src/sentry/integrations/slack/analytics.py

@@ -1,13 +1,13 @@
 from sentry import analytics
 
 
-class SlackIntegrationAssign(analytics.Event):
+class SlackIntegrationAssign(analytics.Event):  # type: ignore
     type = "integrations.slack.assign"
 
     attributes = (analytics.Attribute("actor_id", required=False),)
 
 
-class SlackIntegrationStatus(analytics.Event):
+class SlackIntegrationStatus(analytics.Event):  # type: ignore
     type = "integrations.slack.status"
 
     attributes = (
@@ -17,7 +17,7 @@ class SlackIntegrationStatus(analytics.Event):
     )
 
 
-class SlackIntegrationNotificationSent(analytics.Event):
+class SlackIntegrationNotificationSent(analytics.Event):  # type: ignore
     type = "integrations.slack.notification_sent"
 
     attributes = (

+ 3 - 2
src/sentry/integrations/slack/client.py

@@ -4,13 +4,14 @@ from requests import Response
 from sentry_sdk.tracing import Transaction
 
 from sentry.integrations.client import ApiClient
+from sentry.shared_integrations.client import BaseApiResponse
 from sentry.shared_integrations.exceptions import ApiError
 from sentry.utils import metrics
 
 SLACK_DATADOG_METRIC = "integrations.slack.http_response"
 
 
-class SlackClient(ApiClient):
+class SlackClient(ApiClient):  # type: ignore
     allow_redirects = False
     integration_name = "slack"
     base_url = "https://slack.com/api"
@@ -74,7 +75,7 @@ class SlackClient(ApiClient):
         params: Optional[Mapping[str, Any]] = None,
         json: bool = False,
         timeout: Optional[int] = None,
-    ):
+    ) -> BaseApiResponse:
         # TODO(meredith): Slack actually supports json now for the chat.postMessage so we
         # can update that so we don't have to pass json=False here
         response = self._request(method, path, headers=headers, data=data, params=params, json=json)

+ 14 - 8
src/sentry/integrations/slack/integration.py

@@ -1,6 +1,8 @@
 from collections import namedtuple
+from typing import Any, Mapping, Optional, Sequence
 
 from django.utils.translation import ugettext_lazy as _
+from django.views import View
 
 from sentry import features
 from sentry.identity.pipeline import IdentityProviderPipeline
@@ -12,9 +14,11 @@ from sentry.integrations import (
     IntegrationProvider,
 )
 from sentry.integrations.slack import tasks
+from sentry.models import Integration, Organization
 from sentry.pipeline import NestedPipelineView
 from sentry.shared_integrations.exceptions import ApiError, IntegrationError
 from sentry.utils.http import absolute_uri
+from sentry.utils.json import JSONData
 
 from .client import SlackClient
 from .utils import get_integration_type, logger
@@ -61,12 +65,12 @@ metadata = IntegrationMetadata(
 )
 
 
-class SlackIntegration(IntegrationInstallation):
-    def get_config_data(self):
+class SlackIntegration(IntegrationInstallation):  # type: ignore
+    def get_config_data(self) -> Mapping[str, str]:
         return {"installationType": get_integration_type(self.model)}
 
 
-class SlackIntegrationProvider(IntegrationProvider):
+class SlackIntegrationProvider(IntegrationProvider):  # type: ignore
     key = "slack"
     name = "Slack"
     metadata = metadata
@@ -100,7 +104,7 @@ class SlackIntegrationProvider(IntegrationProvider):
 
     setup_dialog_config = {"width": 600, "height": 900}
 
-    def get_pipeline_views(self):
+    def get_pipeline_views(self) -> Sequence[View]:
         identity_pipeline_config = {
             "oauth_scopes": self.identity_oauth_scopes,
             "user_scopes": self.user_scopes,
@@ -116,8 +120,8 @@ class SlackIntegrationProvider(IntegrationProvider):
 
         return [identity_pipeline_view]
 
-    def get_team_info(self, access_token):
-        headers = {"Authorization": "Bearer %s" % access_token}
+    def get_team_info(self, access_token: str) -> JSONData:
+        headers = {"Authorization": f"Bearer {access_token}"}
 
         client = SlackClient()
         try:
@@ -128,7 +132,7 @@ class SlackIntegrationProvider(IntegrationProvider):
 
         return resp["team"]
 
-    def build_integration(self, state):
+    def build_integration(self, state: Mapping[str, Any]) -> Mapping[str, Any]:
         data = state["identity"]["data"]
         assert data["ok"]
 
@@ -164,7 +168,9 @@ class SlackIntegrationProvider(IntegrationProvider):
 
         return integration
 
-    def post_install(self, integration, organization, extra=None):
+    def post_install(
+        self, integration: Integration, organization: Organization, extra: Optional[Any] = None
+    ) -> None:
         """
         Create Identity records for an organization's users if their emails match in Sentry and Slack
         """

+ 4 - 4
src/sentry/integrations/slack/message_builder/issues.py

@@ -1,5 +1,5 @@
 import re
-from typing import Any, List, Mapping, Optional, Sequence, Tuple, Union
+from typing import Any, List, Mapping, Optional, Sequence, Set, Tuple, Union
 
 from django.core.cache import cache
 
@@ -165,7 +165,7 @@ def build_footer(group: Group, project: Project, rules: Optional[Sequence[Rule]]
 
 
 def build_tag_fields(
-    event_for_tags: Any, tags: Optional[Mapping[str, str]] = None
+    event_for_tags: Any, tags: Optional[Set[str]] = None
 ) -> Sequence[Mapping[str, Union[str, bool]]]:
     fields = []
     if tags:
@@ -306,7 +306,7 @@ class SlackIssuesMessageBuilder(SlackMessageBuilder):
         self,
         group: Group,
         event: Optional[Event] = None,
-        tags: Optional[Mapping[str, str]] = None,
+        tags: Optional[Set[str]] = None,
         identity: Optional[Identity] = None,
         actions: Optional[Sequence[Any]] = None,
         rules: Optional[List[Rule]] = None,
@@ -367,7 +367,7 @@ class SlackIssuesMessageBuilder(SlackMessageBuilder):
 def build_group_attachment(
     group: Group,
     event: Optional[Event] = None,
-    tags: Optional[Mapping[str, str]] = None,
+    tags: Optional[Set[str]] = None,
     identity: Optional[Identity] = None,
     actions: Optional[Sequence[Any]] = None,
     rules: Optional[List[Rule]] = None,

+ 3 - 3
src/sentry/integrations/slack/notifications.py

@@ -1,6 +1,6 @@
 import logging
 from collections import defaultdict
-from typing import AbstractSet, Any, Mapping, Set, Union
+from typing import AbstractSet, Any, Mapping, MutableMapping, Optional, Set, Union
 
 from sentry import analytics
 from sentry.integrations.slack.client import SlackClient  # NOQA
@@ -84,7 +84,7 @@ def get_channel_and_integration_by_team(
 def get_channel_and_token_by_recipient(
     organization: Organization, recipients: AbstractSet[Union[User, Team]]
 ) -> Mapping[Union[User, Team], Mapping[str, str]]:
-    output = defaultdict(dict)
+    output: MutableMapping[Union[User, Team], MutableMapping[str, str]] = defaultdict(dict)
     for recipient in recipients:
         channels_to_integrations = (
             get_channel_and_integration_by_user(recipient, organization)
@@ -123,7 +123,7 @@ def send_notification_as_slack(
     notification: BaseNotification,
     recipients: Union[Set[User], Set[Team]],
     shared_context: Mapping[str, Any],
-    extra_context_by_user_id: Mapping[str, Any],
+    extra_context_by_user_id: Optional[Mapping[int, Mapping[str, Any]]],
 ) -> None:
     """Send an "activity" or "alert rule" notification to a Slack user or team."""
     client = SlackClient()

+ 18 - 13
src/sentry/integrations/slack/notify_action.py

@@ -1,12 +1,15 @@
 import logging
+from typing import Any, Mapping, MutableMapping, Optional, Sequence, Tuple
 
 from django import forms
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext_lazy as _
 
+from sentry.eventstore.models import Event
 from sentry.integrations.slack.message_builder.issues import build_group_attachment
 from sentry.models import Integration
 from sentry.rules.actions.base import IntegrationEventAction
+from sentry.rules.processor import RuleFuture
 from sentry.shared_integrations.exceptions import (
     ApiError,
     ApiRateLimitedError,
@@ -25,13 +28,13 @@ from .utils import (
 logger = logging.getLogger("sentry.rules")
 
 
-class SlackNotifyServiceForm(forms.Form):
+class SlackNotifyServiceForm(forms.Form):  # type: ignore
     workspace = forms.ChoiceField(choices=(), widget=forms.Select())
     channel = forms.CharField(widget=forms.TextInput())
     channel_id = forms.HiddenInput()
     tags = forms.CharField(required=False, widget=forms.TextInput())
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
         # NOTE: Workspace maps directly to the integration ID
         workspace_list = [(i.id, i.name) for i in kwargs.pop("integrations")]
         self.channel_transformer = kwargs.pop("channel_transformer")
@@ -49,7 +52,7 @@ class SlackNotifyServiceForm(forms.Form):
         # in the task (integrations/slack/tasks.py) if the channel_id is found.
         self._pending_save = False
 
-    def clean(self):
+    def clean(self) -> Mapping[str, Any]:
         channel_id = self.data.get("inputChannelId") or self.data.get("input_channel_id")
         if channel_id:
             logger.info(
@@ -62,9 +65,9 @@ class SlackNotifyServiceForm(forms.Form):
             # default to "#" if they have the channel name without the prefix
             channel_prefix = self.data["channel"][0] if self.data["channel"][0] == "@" else "#"
 
-        cleaned_data = super().clean()
+        cleaned_data: MutableMapping[str, Any] = super().clean()
 
-        workspace = cleaned_data.get("workspace")
+        workspace: Optional[int] = cleaned_data.get("workspace")
 
         if channel_id:
             try:
@@ -142,14 +145,14 @@ class SlackNotifyServiceForm(forms.Form):
         return cleaned_data
 
 
-class SlackNotifyServiceAction(IntegrationEventAction):
+class SlackNotifyServiceAction(IntegrationEventAction):  # type: ignore
     form_cls = SlackNotifyServiceForm
     label = "Send a notification to the {workspace} Slack workspace to {channel} and show tags {tags} in notification"
     prompt = "Send a Slack notification"
     provider = "slack"
     integration_key = "workspace"
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.form_fields = {
             "workspace": {
@@ -160,7 +163,7 @@ class SlackNotifyServiceAction(IntegrationEventAction):
             "tags": {"type": "string", "placeholder": "i.e environment,user,my_tag"},
         }
 
-    def after(self, event, state):
+    def after(self, event: Event, state: str) -> Any:
         channel = self.get_option("channel_id")
         tags = set(self.get_tags_list())
 
@@ -170,7 +173,7 @@ class SlackNotifyServiceAction(IntegrationEventAction):
             # Integration removed, rule still active.
             return
 
-        def send_notification(event, futures):
+        def send_notification(event: Event, futures: Sequence[RuleFuture]) -> None:
             rules = [f.rule for f in futures]
             attachments = [build_group_attachment(event.group, event=event, tags=tags, rules=rules)]
 
@@ -200,7 +203,7 @@ class SlackNotifyServiceAction(IntegrationEventAction):
         metrics.incr("notifications.sent", instance="slack.notification", skip_internal=False)
         yield self.future(send_notification, key=key)
 
-    def render_label(self):
+    def render_label(self) -> str:
         tags = self.get_tags_list()
 
         return self.label.format(
@@ -209,13 +212,15 @@ class SlackNotifyServiceAction(IntegrationEventAction):
             tags="[{}]".format(", ".join(tags)),
         )
 
-    def get_tags_list(self):
+    def get_tags_list(self) -> Sequence[str]:
         return [s.strip() for s in self.get_option("tags", "").split(",")]
 
-    def get_form_instance(self):
+    def get_form_instance(self) -> Any:
         return self.form_cls(
             self.data, integrations=self.get_integrations(), channel_transformer=self.get_channel_id
         )
 
-    def get_channel_id(self, integration, name):
+    def get_channel_id(
+        self, integration: Integration, name: str
+    ) -> Tuple[str, Optional[str], bool]:
         return get_channel_id(self.project.organization, integration, name)

+ 2 - 2
src/sentry/integrations/slack/requests/base.py

@@ -1,4 +1,4 @@
-from typing import Any, Mapping, MutableMapping, Optional
+from typing import Any, Dict, Mapping, MutableMapping, Optional
 
 from rest_framework import status as status_
 from rest_framework.request import Request
@@ -97,7 +97,7 @@ class SlackRequest:
         return self._data
 
     @property
-    def logging_data(self) -> Mapping[str, str]:
+    def logging_data(self) -> Dict[str, Any]:
         data = {
             "slack_team_id": self.team_id,
             "slack_channel_id": self.data.get("channel", {}).get("id"),

Некоторые файлы не были показаны из-за большого количества измененных файлов