Skip to content

Commit b3810a9

Browse files
authored
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
1 parent 66d1e16 commit b3810a9

File tree

5 files changed

+234
-3
lines changed

5 files changed

+234
-3
lines changed

src/sentry/api/endpoints/organization_integration_details.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
from uuid import uuid4
22

3+
from rest_framework import serializers
4+
35
from sentry.api.bases.organization import OrganizationIntegrationsPermission
46
from sentry.api.bases.organization_integrations import OrganizationIntegrationBaseEndpoint
57
from sentry.api.serializers import serialize
68
from sentry.api.serializers.models.integration import OrganizationIntegrationSerializer
7-
from sentry.models import AuditLogEntryEvent, ObjectStatus, OrganizationIntegration
9+
from sentry.features.helpers import requires_feature
10+
from sentry.models import AuditLogEntryEvent, Integration, ObjectStatus, OrganizationIntegration
811
from sentry.shared_integrations.exceptions import IntegrationError
912
from sentry.tasks.deletion import delete_organization_integration
1013
from sentry.utils.audit import create_audit_entry
1114

1215

16+
class IntegrationSerializer(serializers.Serializer):
17+
name = serializers.CharField(required=False)
18+
domain = serializers.URLField(required=False, allow_blank=True)
19+
20+
1321
class OrganizationIntegrationDetailsEndpoint(OrganizationIntegrationBaseEndpoint):
1422
permission_classes = (OrganizationIntegrationsPermission,)
1523

@@ -22,6 +30,43 @@ def get(self, request, organization, integration_id):
2230
)
2331
)
2432

33+
@requires_feature("organizations:integrations-custom-scm")
34+
def put(self, request, organization, integration_id):
35+
try:
36+
integration = Integration.objects.get(organizations=organization, id=integration_id)
37+
except Integration.DoesNotExist:
38+
return self.respond(status=404)
39+
40+
if integration.provider != "custom_scm":
41+
return self.respond({"detail": "Invalid action for this integration"}, status=400)
42+
43+
update_kwargs = {}
44+
45+
serializer = IntegrationSerializer(data=request.data, partial=True)
46+
47+
if serializer.is_valid():
48+
data = serializer.validated_data
49+
if data.get("name"):
50+
update_kwargs["name"] = data["name"]
51+
if data.get("domain") is not None:
52+
metadata = integration.metadata
53+
metadata["domain_name"] = data["domain"]
54+
update_kwargs["metadata"] = metadata
55+
56+
integration.update(**update_kwargs)
57+
integration.save()
58+
59+
org_integration = self.get_organization_integration(organization, integration_id)
60+
61+
return self.respond(
62+
serialize(
63+
org_integration,
64+
request.user,
65+
OrganizationIntegrationSerializer(params=request.GET),
66+
)
67+
)
68+
return self.respond(serializer.errors, status=400)
69+
2570
def delete(self, request, organization, integration_id):
2671
# Removing the integration removes the organization
2772
# integrations and all linked issues.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React from 'react';
2+
3+
import {addSuccessMessage} from 'app/actionCreators/indicator';
4+
import {t} from 'app/locale';
5+
import {Integration, Organization} from 'app/types';
6+
import Form from 'app/views/settings/components/forms/form';
7+
import JsonForm from 'app/views/settings/components/forms/jsonForm';
8+
import {Field} from 'app/views/settings/components/forms/type';
9+
10+
type Props = {
11+
integration: Integration;
12+
organization: Organization;
13+
onUpdate: () => void;
14+
};
15+
16+
type State = {
17+
integration: Integration;
18+
};
19+
20+
class IntegrationMainSettings extends React.Component<Props, State> {
21+
state: State = {
22+
integration: this.props.integration,
23+
};
24+
25+
handleSubmitSuccess = (data: Integration) => {
26+
addSuccessMessage(t('Integration updated.'));
27+
this.props.onUpdate();
28+
this.setState({integration: data});
29+
};
30+
31+
get initialData() {
32+
const {integration} = this.props;
33+
34+
return {
35+
name: integration.name,
36+
domain: integration.domainName || '',
37+
};
38+
}
39+
40+
get formFields(): Field[] {
41+
const fields: any[] = [
42+
{
43+
name: 'name',
44+
type: 'string',
45+
required: false,
46+
label: t('Integration Name'),
47+
},
48+
{
49+
name: 'domain',
50+
type: 'string',
51+
required: false,
52+
label: t('Full URL'),
53+
},
54+
];
55+
return fields;
56+
}
57+
58+
render() {
59+
const {integration} = this.state;
60+
const {organization} = this.props;
61+
return (
62+
<Form
63+
initialData={this.initialData}
64+
apiMethod="PUT"
65+
apiEndpoint={`/organizations/${organization.slug}/integrations/${integration.id}/`}
66+
onSubmitSuccess={this.handleSubmitSuccess}
67+
submitLabel={t('Save Settings')}
68+
>
69+
<JsonForm fields={this.formFields} />
70+
</Form>
71+
);
72+
}
73+
}
74+
75+
export default IntegrationMainSettings;

static/app/views/settings/organizationIntegrations/configureIntegration.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import IntegrationCodeMappings from 'app/views/organizationIntegrations/integrat
2020
import IntegrationExternalTeamMappings from 'app/views/organizationIntegrations/integrationExternalTeamMappings';
2121
import IntegrationExternalUserMappings from 'app/views/organizationIntegrations/integrationExternalUserMappings';
2222
import IntegrationItem from 'app/views/organizationIntegrations/integrationItem';
23+
import IntegrationMainSettings from 'app/views/organizationIntegrations/integrationMainSettings';
2324
import IntegrationRepos from 'app/views/organizationIntegrations/integrationRepos';
2425
import IntegrationServerlessFunctions from 'app/views/organizationIntegrations/integrationServerlessFunctions';
2526
import Form from 'app/views/settings/components/forms/form';
@@ -35,7 +36,7 @@ type Props = RouteComponentProps<RouteParams, {}> & {
3536
organization: Organization;
3637
};
3738

38-
type Tab = 'repos' | 'codeMappings' | 'userMappings' | 'teamMappings';
39+
type Tab = 'repos' | 'codeMappings' | 'userMappings' | 'teamMappings' | 'settings';
3940

4041
type State = AsyncView['state'] & {
4142
config: {providers: IntegrationProvider[]};
@@ -95,6 +96,15 @@ class ConfigureIntegration extends AsyncView<Props, State> {
9596
return this.props.organization.features.includes('integrations-codeowners');
9697
}
9798

99+
isCustomIntegration() {
100+
const {integration} = this.state;
101+
const {organization} = this.props;
102+
return (
103+
organization.features.includes('integrations-custom-scm') &&
104+
integration.provider.key === 'custom_scm'
105+
);
106+
}
107+
98108
onTabChange = (value: Tab) => {
99109
this.setState({tab: value});
100110
};
@@ -233,6 +243,10 @@ class ConfigureIntegration extends AsyncView<Props, State> {
233243
...(this.hasCodeOwners() ? [['teamMappings', t('Team Mappings')]] : []),
234244
];
235245

246+
if (this.isCustomIntegration()) {
247+
tabs.unshift(['settings', t('Settings')]);
248+
}
249+
236250
return (
237251
<Fragment>
238252
<NavTabs underlined>
@@ -253,6 +267,7 @@ class ConfigureIntegration extends AsyncView<Props, State> {
253267

254268
renderTabContent(tab: Tab, provider: IntegrationProvider) {
255269
const {integration} = this.state;
270+
const {organization} = this.props;
256271
switch (tab) {
257272
case 'codeMappings':
258273
return <IntegrationCodeMappings integration={integration} />;
@@ -262,6 +277,14 @@ class ConfigureIntegration extends AsyncView<Props, State> {
262277
return <IntegrationExternalUserMappings integration={integration} />;
263278
case 'teamMappings':
264279
return <IntegrationExternalTeamMappings integration={integration} />;
280+
case 'settings':
281+
return (
282+
<IntegrationMainSettings
283+
onUpdate={this.onUpdateIntegration}
284+
organization={organization}
285+
integration={integration}
286+
/>
287+
);
265288
default:
266289
return this.renderMainTab(provider);
267290
}

tests/acceptance/test_organization_integration_external_mappings.py renamed to tests/acceptance/test_organization_integration_configuration_tabs.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from sentry.testutils import AcceptanceTestCase
33

44

5-
class OrganizationExternalMappings(AcceptanceTestCase):
5+
class OrganizationIntegrationConfigurationTabs(AcceptanceTestCase):
66
def setUp(self):
77
super().setUp()
88
self.login_as(self.user)
@@ -24,6 +24,7 @@ def setUp(self):
2424
name="getsentry/sentry",
2525
provider="integrations:github",
2626
integration_id=self.integration.id,
27+
project=self.project,
2728
url="https://github.com/getsentry/sentry",
2829
)
2930

@@ -101,3 +102,36 @@ def test_external_team_mappings(self):
101102
self.browser.click('[aria-label="Save Changes"]')
102103
self.browser.wait_until_not(".loading-indicator")
103104
self.browser.snapshot("integrations - one external team mapping")
105+
106+
def test_settings_tab(self):
107+
provider = "custom_scm"
108+
integration = Integration.objects.create(
109+
provider=provider,
110+
external_id="123456789",
111+
name="Some Org",
112+
metadata={
113+
"domain_name": "https://github.com/some-org/",
114+
},
115+
)
116+
integration.add_organization(self.organization, self.user)
117+
with self.feature(
118+
{
119+
"organizations:integrations-codeowners": True,
120+
"organizations:integrations-stacktrace-link": True,
121+
"organizations:integrations-custom-scm": True,
122+
}
123+
):
124+
self.browser.get(
125+
f"/settings/{self.organization.slug}/integrations/{provider}/{integration.id}/"
126+
)
127+
self.browser.wait_until_not(".loading-indicator")
128+
self.browser.click(".nav-tabs li:nth-child(1) a")
129+
self.browser.wait_until_not(".loading-indicator")
130+
131+
name = self.browser.find_element_by_name("name")
132+
name.clear()
133+
name.send_keys("New Name")
134+
135+
self.browser.click('[aria-label="Save Settings"]')
136+
self.browser.wait_until('[data-test-id="toast-success"]')
137+
self.browser.snapshot("integrations - custom scm settings")

tests/sentry/api/endpoints/test_organization_integration_details.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Repository,
77
)
88
from sentry.testutils import APITestCase
9+
from sentry.testutils.helpers import with_feature
910

1011

1112
class OrganizationIntegrationDetailsTest(APITestCase):
@@ -81,3 +82,56 @@ def test_removal_default_identity_already_removed(self):
8182
assert not OrganizationIntegration.objects.filter(
8283
integration=self.integration, organization=self.org
8384
).exists()
85+
86+
def test_no_access_put_request(self):
87+
data = {"name": "Example Name"}
88+
89+
response = self.client.put(self.path, format="json", data=data)
90+
assert response.status_code == 404
91+
92+
@with_feature("organizations:integrations-custom-scm")
93+
def test_valid_put_request(self):
94+
integration = Integration.objects.create(
95+
provider="custom_scm", name="A Name", external_id="1232948573948579127"
96+
)
97+
integration.add_organization(self.org, self.user)
98+
path = f"/api/0/organizations/{self.org.slug}/integrations/{integration.id}/"
99+
100+
data = {"name": "New Name", "domain": "https://example.com/"}
101+
102+
response = self.client.put(path, format="json", data=data)
103+
assert response.status_code == 200
104+
105+
updated = Integration.objects.get(id=integration.id)
106+
assert updated.name == "New Name"
107+
assert updated.metadata["domain_name"] == "https://example.com/"
108+
109+
@with_feature("organizations:integrations-custom-scm")
110+
def test_partial_updates(self):
111+
integration = Integration.objects.create(
112+
provider="custom_scm", name="A Name", external_id="1232948573948579127"
113+
)
114+
integration.add_organization(self.org, self.user)
115+
path = f"/api/0/organizations/{self.org.slug}/integrations/{integration.id}/"
116+
117+
data = {"domain": "https://example.com/"}
118+
response = self.client.put(path, format="json", data=data)
119+
assert response.status_code == 200
120+
121+
updated = Integration.objects.get(id=integration.id)
122+
assert updated.name == "A Name"
123+
assert updated.metadata["domain_name"] == "https://example.com/"
124+
125+
data = {"name": "Newness"}
126+
response = self.client.put(path, format="json", data=data)
127+
assert response.status_code == 200
128+
updated = Integration.objects.get(id=integration.id)
129+
assert updated.name == "Newness"
130+
assert updated.metadata["domain_name"] == "https://example.com/"
131+
132+
data = {"domain": ""}
133+
response = self.client.put(path, format="json", data=data)
134+
assert response.status_code == 200
135+
updated = Integration.objects.get(id=integration.id)
136+
assert updated.name == "Newness"
137+
assert updated.metadata["domain_name"] == ""

0 commit comments

Comments
 (0)