Browse Source

feat(opsgenie): add basic configuration flow (#52940)

Add a basic configuration flow to the Opsgenie integration. Users can
manually add teams and API keys, which are saved to the
OrganizationIntegration config.

Fixes ER-1709
Fixes ER-1710

<img width="1512" alt="Screenshot 2023-07-17 at 12 01 36 PM"
src="https://github.com/getsentry/sentry/assets/83109586/fb8d332c-5d06-4e13-937c-5681f09226d0">
<img width="1512" alt="Screenshot 2023-07-17 at 12 01 50 PM"
src="https://github.com/getsentry/sentry/assets/83109586/dad305b3-efbf-43bb-8357-cb25e9a8b313">

---------

Co-authored-by: Mark Story <mark@mark-story.com>
Michelle Fu 1 year ago
parent
commit
7365d96e83

+ 31 - 0
src/sentry/integrations/opsgenie/client.py

@@ -1,6 +1,12 @@
 from __future__ import annotations
 
+from urllib.parse import quote
+
 from sentry.integrations.client import ApiClient
+from sentry.models import Integration
+from sentry.services.hybrid_cloud.integration.model import RpcIntegration
+from sentry.shared_integrations.client.base import BaseApiResponseX
+from sentry.shared_integrations.client.proxy import IntegrationProxyClient
 
 OPSGENIE_API_VERSION = "v2"
 
@@ -22,3 +28,28 @@ class OpsgenieSetupClient(ApiClient):
     def get_account(self):
         headers = {"Authorization": "GenieKey " + self.api_key}
         return self.get(path="/account", headers=headers)
+
+
+class OpsgenieClient(IntegrationProxyClient):
+    integration_name = "opsgenie"
+
+    def __init__(
+        self, integration: RpcIntegration | Integration, org_integration_id: int | None
+    ) -> None:
+        self.integration = integration
+        self.base_url = f"{self.metadata['base_url']}{OPSGENIE_API_VERSION}"
+        self.api_key = self.metadata["api_key"]
+        super().__init__(org_integration_id=org_integration_id)
+
+    @property
+    def metadata(self):
+        return self.integration.metadata
+
+    # This doesn't work if the team name is "." or "..", which Opsgenie allows for some reason
+    # despite their API not working with these names.
+    def get_team_id(self, integration_key: str, team_name: str) -> BaseApiResponseX:
+        params = {"identifierType": "name"}
+        quoted_name = quote(team_name)
+        path = f"/teams/{quoted_name}"
+        headers = {"Authorization": "GenieKey " + integration_key}
+        return self.get(path=path, headers=headers, params=params)

+ 40 - 3
src/sentry/integrations/opsgenie/integration.py

@@ -1,13 +1,14 @@
 from __future__ import annotations
 
 import logging
-from typing import Any, Mapping, Sequence
+from typing import Any, Mapping, MutableMapping, Sequence
 
 from django import forms
 from django.http import HttpResponse
 from django.utils.translation import gettext_lazy as _
 from requests.exceptions import MissingSchema
 from rest_framework.request import Request
+from rest_framework.serializers import ValidationError
 
 from sentry.integrations.base import (
     FeatureDescription,
@@ -21,7 +22,7 @@ from sentry.shared_integrations.exceptions import ApiError, IntegrationError
 from sentry.utils.http import absolute_uri
 from sentry.web.helpers import render_to_response
 
-from .client import OpsgenieSetupClient
+from .client import OpsgenieClient, OpsgenieSetupClient
 
 logger = logging.getLogger("sentry.integrations.opsgenie")
 
@@ -120,7 +121,43 @@ class InstallationGuideView(PipelineView):
 
 
 class OpsgenieIntegration(IntegrationInstallation):
-    pass
+    def get_client(self) -> Any:
+        org_integration_id = self.org_integration.id if self.org_integration else None
+        return OpsgenieClient(integration=self.model, org_integration_id=org_integration_id)
+
+    def get_organization_config(self) -> Sequence[Any]:
+        fields = [
+            {
+                "name": "team_table",
+                "type": "table",
+                "label": "Opsgenie teams with the Sentry integration enabled",
+                "help": "If teams need to be updated, deleted, or added manually please do so here. Alert rules will need to be individually updated for any additions or deletions of teams.",
+                "addButtonText": "",
+                "columnLabels": {"team": "Team", "integration_key": "Integration Key"},
+                "columnKeys": ["team", "integration_key"],
+                "confirmDeleteMessage": "Any alert rules associated with this team will stop working. The rules will still exist but will show a `removed` team.",
+            }
+        ]
+
+        return fields
+
+    def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
+        client = self.get_client()
+        # get the team ID/test the API key for a newly added row
+        teams = data["team_table"]
+        unsaved_teams = [team for team in teams if team["id"] == ""]
+        for team in unsaved_teams:
+            try:
+                resp = client.get_team_id(
+                    integration_key=team["integration_key"], team_name=team["team"]
+                )
+                team["id"] = resp["data"]["id"]
+            except ApiError:
+                raise ValidationError(
+                    {"api_key": ["Could not save due to invalid team name or integration key."]}
+                )
+
+        return super().update_organization_config(data)
 
 
 class OpsgenieIntegrationProvider(IntegrationProvider):

+ 48 - 0
tests/sentry/integrations/opsgenie/test_client.py

@@ -0,0 +1,48 @@
+import responses
+
+from sentry.models import Integration, OrganizationIntegration
+from sentry.testutils import APITestCase
+
+EXTERNAL_ID = "test-app"
+METADATA = {
+    "api_key": "1234-ABCD",
+    "base_url": "https://api.opsgenie.com/",
+    "domain_name": "test-app.app.opsgenie.com",
+}
+
+
+class OpsgenieClientTest(APITestCase):
+    def create_integration(self):
+        integration = Integration.objects.create(
+            provider="opsgenie", name="test-app", external_id=EXTERNAL_ID, metadata=METADATA
+        )
+        integration.add_organization(self.organization, self.user)
+        return integration
+
+    def setUp(self) -> None:
+        self.login_as(self.user)
+        self.integration = self.create_integration()
+        self.installation = self.integration.get_installation(self.organization.id)
+
+    def test_get_client(self):
+        client = self.installation.get_client()
+        assert client.integration == self.installation.model
+        assert client.base_url == METADATA["base_url"] + "v2"
+        assert client.api_key == METADATA["api_key"]
+
+    @responses.activate
+    def test_get_team_id(self):
+        client = self.installation.get_client()
+
+        org_integration = OrganizationIntegration.objects.get(
+            organization_id=self.organization.id, integration_id=self.integration.id
+        )
+        org_integration.config = {
+            "team_table": [{"id": "", "team": "cool-team", "integration_key": "1234-5678"}]
+        }
+        org_integration.save()
+        resp_data = {"data": {"id": "123-id", "name": "cool-team"}}
+        responses.add(responses.GET, url=f"{client.base_url}/teams/cool-team", json=resp_data)
+
+        resp = client.get_team_id(integration_key="123-key", team_name="cool-team")
+        assert resp == resp_data

+ 47 - 0
tests/sentry/integrations/opsgenie/test_integration.py

@@ -1,14 +1,24 @@
 import pytest
 import responses
+from rest_framework.serializers import ValidationError
 
 from sentry.integrations.opsgenie.integration import OpsgenieIntegrationProvider
 from sentry.models.integrations.integration import Integration
 from sentry.models.integrations.organization_integration import OrganizationIntegration
 from sentry.shared_integrations.exceptions import IntegrationError
 from sentry.testutils import IntegrationTestCase
+from sentry.testutils.silo import control_silo_test
 from sentry.utils import json
 
+EXTERNAL_ID = "test-app"
+METADATA = {
+    "api_key": "1234-ABCD",
+    "base_url": "https://api.opsgenie.com/",
+    "domain_name": "test-app.app.opsgenie.com",
+}
 
+
+@control_silo_test(stable=True)
 class OpsgenieIntegrationTest(IntegrationTestCase):
     provider = OpsgenieIntegrationProvider
     config = {"base_url": "https://api.opsgenie.com/", "api_key": "123"}
@@ -82,3 +92,40 @@ class OpsgenieIntegrationTest(IntegrationTestCase):
         with pytest.raises(IntegrationError) as error:
             provider.get_account_info(base_url=bad_url, api_key=self.config["api_key"])
         assert str(error.value) == "Invalid URL provided."
+
+    @responses.activate
+    def test_update_config_valid(self):
+        integration = Integration.objects.create(
+            provider="opsgenie", name="test-app", external_id=EXTERNAL_ID, metadata=METADATA
+        )
+
+        integration.add_organization(self.organization, self.user)
+        installation = integration.get_installation(self.organization.id)
+        opsgenie_client = installation.get_client()
+
+        data = {"team_table": [{"id": "", "team": "cool-team", "integration_key": "1234-5678"}]}
+        resp_data = {"data": {"id": "123-id", "name": "cool-team"}}
+        responses.add(
+            responses.GET, url=f"{opsgenie_client.base_url}/teams/cool-team", json=resp_data
+        )
+
+        installation.update_organization_config(data)
+        assert installation.get_config_data() == {
+            "team_table": [{"id": "123-id", "team": "cool-team", "integration_key": "1234-5678"}]
+        }
+
+    @responses.activate
+    def test_update_config_invalid(self):
+        integration = Integration.objects.create(
+            provider="opsgenie", name="test-app", external_id=EXTERNAL_ID, metadata=METADATA
+        )
+
+        integration.add_organization(self.organization, self.user)
+        installation = integration.get_installation(self.organization.id)
+        opsgenie_client = installation.get_client()
+
+        data = {"team_table": [{"id": "", "team": "cool-team", "integration_key": "1234-bad"}]}
+        responses.add(responses.GET, url=f"{opsgenie_client.base_url}/teams/cool-team")
+        with pytest.raises(ValidationError):
+            installation.update_organization_config(data)
+        assert installation.get_config_data() == {}