Browse Source

feat(custom-scm): Add Settings Tab (#25803)

* feat(custom-sc): Custom source code integration

* update form

* rename

* feat(custom-scm): Add Settings Tab

* add feature gating and some tests

* add test

* rename test file

* allow blank domain
MeredithAnya 3 years ago
parent
commit
b3810a9d0f

+ 46 - 1
src/sentry/api/endpoints/organization_integration_details.py

@@ -1,15 +1,23 @@
 from uuid import uuid4
 
+from rest_framework import serializers
+
 from sentry.api.bases.organization import OrganizationIntegrationsPermission
 from sentry.api.bases.organization_integrations import OrganizationIntegrationBaseEndpoint
 from sentry.api.serializers import serialize
 from sentry.api.serializers.models.integration import OrganizationIntegrationSerializer
-from sentry.models import AuditLogEntryEvent, ObjectStatus, OrganizationIntegration
+from sentry.features.helpers import requires_feature
+from sentry.models import AuditLogEntryEvent, Integration, ObjectStatus, OrganizationIntegration
 from sentry.shared_integrations.exceptions import IntegrationError
 from sentry.tasks.deletion import delete_organization_integration
 from sentry.utils.audit import create_audit_entry
 
 
+class IntegrationSerializer(serializers.Serializer):
+    name = serializers.CharField(required=False)
+    domain = serializers.URLField(required=False, allow_blank=True)
+
+
 class OrganizationIntegrationDetailsEndpoint(OrganizationIntegrationBaseEndpoint):
     permission_classes = (OrganizationIntegrationsPermission,)
 
@@ -22,6 +30,43 @@ class OrganizationIntegrationDetailsEndpoint(OrganizationIntegrationBaseEndpoint
             )
         )
 
+    @requires_feature("organizations:integrations-custom-scm")
+    def put(self, request, organization, integration_id):
+        try:
+            integration = Integration.objects.get(organizations=organization, id=integration_id)
+        except Integration.DoesNotExist:
+            return self.respond(status=404)
+
+        if integration.provider != "custom_scm":
+            return self.respond({"detail": "Invalid action for this integration"}, status=400)
+
+        update_kwargs = {}
+
+        serializer = IntegrationSerializer(data=request.data, partial=True)
+
+        if serializer.is_valid():
+            data = serializer.validated_data
+            if data.get("name"):
+                update_kwargs["name"] = data["name"]
+            if data.get("domain") is not None:
+                metadata = integration.metadata
+                metadata["domain_name"] = data["domain"]
+                update_kwargs["metadata"] = metadata
+
+            integration.update(**update_kwargs)
+            integration.save()
+
+            org_integration = self.get_organization_integration(organization, integration_id)
+
+            return self.respond(
+                serialize(
+                    org_integration,
+                    request.user,
+                    OrganizationIntegrationSerializer(params=request.GET),
+                )
+            )
+        return self.respond(serializer.errors, status=400)
+
     def delete(self, request, organization, integration_id):
         # Removing the integration removes the organization
         # integrations and all linked issues.

+ 75 - 0
static/app/views/organizationIntegrations/integrationMainSettings.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+
+import {addSuccessMessage} from 'app/actionCreators/indicator';
+import {t} from 'app/locale';
+import {Integration, Organization} from 'app/types';
+import Form from 'app/views/settings/components/forms/form';
+import JsonForm from 'app/views/settings/components/forms/jsonForm';
+import {Field} from 'app/views/settings/components/forms/type';
+
+type Props = {
+  integration: Integration;
+  organization: Organization;
+  onUpdate: () => void;
+};
+
+type State = {
+  integration: Integration;
+};
+
+class IntegrationMainSettings extends React.Component<Props, State> {
+  state: State = {
+    integration: this.props.integration,
+  };
+
+  handleSubmitSuccess = (data: Integration) => {
+    addSuccessMessage(t('Integration updated.'));
+    this.props.onUpdate();
+    this.setState({integration: data});
+  };
+
+  get initialData() {
+    const {integration} = this.props;
+
+    return {
+      name: integration.name,
+      domain: integration.domainName || '',
+    };
+  }
+
+  get formFields(): Field[] {
+    const fields: any[] = [
+      {
+        name: 'name',
+        type: 'string',
+        required: false,
+        label: t('Integration Name'),
+      },
+      {
+        name: 'domain',
+        type: 'string',
+        required: false,
+        label: t('Full URL'),
+      },
+    ];
+    return fields;
+  }
+
+  render() {
+    const {integration} = this.state;
+    const {organization} = this.props;
+    return (
+      <Form
+        initialData={this.initialData}
+        apiMethod="PUT"
+        apiEndpoint={`/organizations/${organization.slug}/integrations/${integration.id}/`}
+        onSubmitSuccess={this.handleSubmitSuccess}
+        submitLabel={t('Save Settings')}
+      >
+        <JsonForm fields={this.formFields} />
+      </Form>
+    );
+  }
+}
+
+export default IntegrationMainSettings;

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

@@ -20,6 +20,7 @@ import IntegrationCodeMappings from 'app/views/organizationIntegrations/integrat
 import IntegrationExternalTeamMappings from 'app/views/organizationIntegrations/integrationExternalTeamMappings';
 import IntegrationExternalUserMappings from 'app/views/organizationIntegrations/integrationExternalUserMappings';
 import IntegrationItem from 'app/views/organizationIntegrations/integrationItem';
+import IntegrationMainSettings from 'app/views/organizationIntegrations/integrationMainSettings';
 import IntegrationRepos from 'app/views/organizationIntegrations/integrationRepos';
 import IntegrationServerlessFunctions from 'app/views/organizationIntegrations/integrationServerlessFunctions';
 import Form from 'app/views/settings/components/forms/form';
@@ -35,7 +36,7 @@ type Props = RouteComponentProps<RouteParams, {}> & {
   organization: Organization;
 };
 
-type Tab = 'repos' | 'codeMappings' | 'userMappings' | 'teamMappings';
+type Tab = 'repos' | 'codeMappings' | 'userMappings' | 'teamMappings' | 'settings';
 
 type State = AsyncView['state'] & {
   config: {providers: IntegrationProvider[]};
@@ -95,6 +96,15 @@ class ConfigureIntegration extends AsyncView<Props, State> {
     return this.props.organization.features.includes('integrations-codeowners');
   }
 
+  isCustomIntegration() {
+    const {integration} = this.state;
+    const {organization} = this.props;
+    return (
+      organization.features.includes('integrations-custom-scm') &&
+      integration.provider.key === 'custom_scm'
+    );
+  }
+
   onTabChange = (value: Tab) => {
     this.setState({tab: value});
   };
@@ -233,6 +243,10 @@ class ConfigureIntegration extends AsyncView<Props, State> {
       ...(this.hasCodeOwners() ? [['teamMappings', t('Team Mappings')]] : []),
     ];
 
+    if (this.isCustomIntegration()) {
+      tabs.unshift(['settings', t('Settings')]);
+    }
+
     return (
       <Fragment>
         <NavTabs underlined>
@@ -253,6 +267,7 @@ class ConfigureIntegration extends AsyncView<Props, State> {
 
   renderTabContent(tab: Tab, provider: IntegrationProvider) {
     const {integration} = this.state;
+    const {organization} = this.props;
     switch (tab) {
       case 'codeMappings':
         return <IntegrationCodeMappings integration={integration} />;
@@ -262,6 +277,14 @@ class ConfigureIntegration extends AsyncView<Props, State> {
         return <IntegrationExternalUserMappings integration={integration} />;
       case 'teamMappings':
         return <IntegrationExternalTeamMappings integration={integration} />;
+      case 'settings':
+        return (
+          <IntegrationMainSettings
+            onUpdate={this.onUpdateIntegration}
+            organization={organization}
+            integration={integration}
+          />
+        );
       default:
         return this.renderMainTab(provider);
     }

+ 35 - 1
tests/acceptance/test_organization_integration_external_mappings.py → tests/acceptance/test_organization_integration_configuration_tabs.py

@@ -2,7 +2,7 @@ from sentry.models import Integration
 from sentry.testutils import AcceptanceTestCase
 
 
-class OrganizationExternalMappings(AcceptanceTestCase):
+class OrganizationIntegrationConfigurationTabs(AcceptanceTestCase):
     def setUp(self):
         super().setUp()
         self.login_as(self.user)
@@ -24,6 +24,7 @@ class OrganizationExternalMappings(AcceptanceTestCase):
             name="getsentry/sentry",
             provider="integrations:github",
             integration_id=self.integration.id,
+            project=self.project,
             url="https://github.com/getsentry/sentry",
         )
 
@@ -101,3 +102,36 @@ class OrganizationExternalMappings(AcceptanceTestCase):
             self.browser.click('[aria-label="Save Changes"]')
             self.browser.wait_until_not(".loading-indicator")
             self.browser.snapshot("integrations - one external team mapping")
+
+    def test_settings_tab(self):
+        provider = "custom_scm"
+        integration = Integration.objects.create(
+            provider=provider,
+            external_id="123456789",
+            name="Some Org",
+            metadata={
+                "domain_name": "https://github.com/some-org/",
+            },
+        )
+        integration.add_organization(self.organization, self.user)
+        with self.feature(
+            {
+                "organizations:integrations-codeowners": True,
+                "organizations:integrations-stacktrace-link": True,
+                "organizations:integrations-custom-scm": True,
+            }
+        ):
+            self.browser.get(
+                f"/settings/{self.organization.slug}/integrations/{provider}/{integration.id}/"
+            )
+            self.browser.wait_until_not(".loading-indicator")
+            self.browser.click(".nav-tabs li:nth-child(1) a")
+            self.browser.wait_until_not(".loading-indicator")
+
+            name = self.browser.find_element_by_name("name")
+            name.clear()
+            name.send_keys("New Name")
+
+            self.browser.click('[aria-label="Save Settings"]')
+            self.browser.wait_until('[data-test-id="toast-success"]')
+            self.browser.snapshot("integrations - custom scm settings")

+ 54 - 0
tests/sentry/api/endpoints/test_organization_integration_details.py

@@ -6,6 +6,7 @@ from sentry.models import (
     Repository,
 )
 from sentry.testutils import APITestCase
+from sentry.testutils.helpers import with_feature
 
 
 class OrganizationIntegrationDetailsTest(APITestCase):
@@ -81,3 +82,56 @@ class OrganizationIntegrationDetailsTest(APITestCase):
             assert not OrganizationIntegration.objects.filter(
                 integration=self.integration, organization=self.org
             ).exists()
+
+    def test_no_access_put_request(self):
+        data = {"name": "Example Name"}
+
+        response = self.client.put(self.path, format="json", data=data)
+        assert response.status_code == 404
+
+    @with_feature("organizations:integrations-custom-scm")
+    def test_valid_put_request(self):
+        integration = Integration.objects.create(
+            provider="custom_scm", name="A Name", external_id="1232948573948579127"
+        )
+        integration.add_organization(self.org, self.user)
+        path = f"/api/0/organizations/{self.org.slug}/integrations/{integration.id}/"
+
+        data = {"name": "New Name", "domain": "https://example.com/"}
+
+        response = self.client.put(path, format="json", data=data)
+        assert response.status_code == 200
+
+        updated = Integration.objects.get(id=integration.id)
+        assert updated.name == "New Name"
+        assert updated.metadata["domain_name"] == "https://example.com/"
+
+    @with_feature("organizations:integrations-custom-scm")
+    def test_partial_updates(self):
+        integration = Integration.objects.create(
+            provider="custom_scm", name="A Name", external_id="1232948573948579127"
+        )
+        integration.add_organization(self.org, self.user)
+        path = f"/api/0/organizations/{self.org.slug}/integrations/{integration.id}/"
+
+        data = {"domain": "https://example.com/"}
+        response = self.client.put(path, format="json", data=data)
+        assert response.status_code == 200
+
+        updated = Integration.objects.get(id=integration.id)
+        assert updated.name == "A Name"
+        assert updated.metadata["domain_name"] == "https://example.com/"
+
+        data = {"name": "Newness"}
+        response = self.client.put(path, format="json", data=data)
+        assert response.status_code == 200
+        updated = Integration.objects.get(id=integration.id)
+        assert updated.name == "Newness"
+        assert updated.metadata["domain_name"] == "https://example.com/"
+
+        data = {"domain": ""}
+        response = self.client.put(path, format="json", data=data)
+        assert response.status_code == 200
+        updated = Integration.objects.get(id=integration.id)
+        assert updated.name == "Newness"
+        assert updated.metadata["domain_name"] == ""