Skip to content

Commit eba6218

Browse files
committed
feat(bitbucket): Add API-driven pipeline backend for Bitbucket integration setup
Implement `get_pipeline_api_steps()` on `BitbucketIntegrationProvider` with a single authorize step that verifies the Atlassian Connect JWT from Bitbucket's addon authorization flow. The frontend posts the JWT received from the popup callback, and the backend validates it against the original callback path using `get_integration_from_jwt`. Updates `build_integration()` to handle both legacy and API state paths. Ref [VDY-41](https://linear.app/getsentry/issue/VDY-41/bitbucket-api-driven-integration-setup)
1 parent 01a1fc9 commit eba6218

File tree

2 files changed

+146
-1
lines changed

2 files changed

+146
-1
lines changed

src/sentry/integrations/bitbucket/integration.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
from django.http.request import HttpRequest
77
from django.http.response import HttpResponseBase
8+
from django.urls import reverse
89
from django.utils.datastructures import OrderedSet
910
from django.utils.translation import gettext_lazy as _
11+
from rest_framework.fields import CharField
12+
from rest_framework.serializers import Serializer
1013

1114
from sentry.identity.pipeline import IdentityPipeline
1215
from sentry.integrations.base import (
@@ -25,6 +28,7 @@
2528
from sentry.integrations.types import IntegrationProviderSlug
2629
from sentry.integrations.utils.atlassian_connect import (
2730
AtlassianConnectValidationError,
31+
get_integration_from_jwt,
2832
get_integration_from_request,
2933
)
3034
from sentry.integrations.utils.metrics import (
@@ -34,7 +38,8 @@
3438
from sentry.models.apitoken import generate_token
3539
from sentry.models.repository import Repository
3640
from sentry.organizations.services.organization.model import RpcOrganization
37-
from sentry.pipeline.views.base import PipelineView
41+
from sentry.pipeline.types import PipelineStepResult
42+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
3843
from sentry.pipeline.views.nested import NestedPipelineView
3944
from sentry.shared_integrations.exceptions import ApiError
4045
from sentry.utils.http import absolute_uri
@@ -194,6 +199,46 @@ def username(self):
194199
return self.model.name
195200

196201

202+
class BitbucketVerifySerializer(Serializer):
203+
jwt = CharField(required=True)
204+
205+
206+
class BitbucketAuthorizeApiStep:
207+
step_name = "authorize"
208+
209+
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]:
210+
descriptor_uri = absolute_uri("/extensions/bitbucket/descriptor/")
211+
authorize_url = (
212+
f"https://bitbucket.org/site/addons/authorize?descriptor_uri={descriptor_uri}"
213+
)
214+
return {"authorizeUrl": authorize_url}
215+
216+
def get_serializer_cls(self) -> type:
217+
return BitbucketVerifySerializer
218+
219+
def handle_post(
220+
self,
221+
validated_data: dict[str, Any],
222+
pipeline: IntegrationPipeline,
223+
request: HttpRequest,
224+
) -> PipelineStepResult:
225+
callback_path = reverse(
226+
"sentry-extension-setup",
227+
kwargs={"provider_id": IntegrationProviderSlug.BITBUCKET.value},
228+
)
229+
try:
230+
integration = get_integration_from_jwt(
231+
token=validated_data["jwt"],
232+
path=callback_path,
233+
provider=IntegrationProviderSlug.BITBUCKET.value,
234+
query_params=None,
235+
)
236+
except AtlassianConnectValidationError:
237+
return PipelineStepResult.error("Unable to verify installation.")
238+
pipeline.bind_state("external_id", integration.external_id)
239+
return PipelineStepResult.advance()
240+
241+
197242
class BitbucketIntegrationProvider(IntegrationProvider):
198243
key = IntegrationProviderSlug.BITBUCKET.value
199244
name = "Bitbucket"
@@ -220,6 +265,9 @@ def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
220265
VerifyInstallation(),
221266
]
222267

268+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
269+
return [BitbucketAuthorizeApiStep()]
270+
223271
def post_install(
224272
self,
225273
integration: Integration,

tests/sentry/integrations/bitbucket/test_integration.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
14
from unittest.mock import MagicMock, patch
25
from urllib.parse import quote, urlencode
36

@@ -7,6 +10,10 @@
710

811
from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider
912
from sentry.integrations.models.integration import Integration
13+
from sentry.integrations.models.organization_integration import OrganizationIntegration
14+
from sentry.integrations.pipeline import IntegrationPipeline
15+
from sentry.integrations.services.integration.model import RpcIntegration
16+
from sentry.integrations.utils.atlassian_connect import AtlassianConnectValidationError
1017
from sentry.models.repository import Repository
1118
from sentry.shared_integrations.exceptions import IntegrationError
1219
from sentry.silo.base import SiloMode
@@ -268,3 +275,93 @@ def test_get_repository_choices_failure_lifecycle(
268275
installation.get_repository_choices(None, {})
269276
assert mock_record_halt.call_count == 0
270277
assert mock_record_failure.call_count == 1
278+
279+
280+
@control_silo_test
281+
class BitbucketApiPipelineTest(APITestCase):
282+
endpoint = "sentry-api-0-organization-pipeline"
283+
method = "post"
284+
285+
def setUp(self) -> None:
286+
super().setUp()
287+
self.login_as(self.user)
288+
self.subject = "connect:1234567"
289+
self.shared_secret = "234567890"
290+
self.integration = self.create_provider_integration(
291+
provider="bitbucket",
292+
external_id=self.subject,
293+
name="sentryuser",
294+
metadata={
295+
"base_url": "https://api.bitbucket.org",
296+
"shared_secret": self.shared_secret,
297+
},
298+
)
299+
300+
def _get_pipeline_url(self) -> str:
301+
return reverse(
302+
self.endpoint,
303+
args=[self.organization.slug, IntegrationPipeline.pipeline_name],
304+
)
305+
306+
def _initialize_pipeline(self) -> Any:
307+
return self.client.post(
308+
self._get_pipeline_url(),
309+
data={"action": "initialize", "provider": "bitbucket"},
310+
format="json",
311+
)
312+
313+
def _advance_step(self, data: dict[str, Any]) -> Any:
314+
return self.client.post(self._get_pipeline_url(), data=data, format="json")
315+
316+
@responses.activate
317+
def test_initialize_pipeline(self) -> None:
318+
resp = self._initialize_pipeline()
319+
assert resp.status_code == 200
320+
assert resp.data["step"] == "authorize"
321+
assert resp.data["stepIndex"] == 0
322+
assert resp.data["totalSteps"] == 1
323+
assert resp.data["provider"] == "bitbucket"
324+
assert "authorizeUrl" in resp.data["data"]
325+
assert "bitbucket.org/site/addons/authorize" in resp.data["data"]["authorizeUrl"]
326+
327+
@responses.activate
328+
def test_missing_jwt(self) -> None:
329+
self._initialize_pipeline()
330+
resp = self._advance_step({})
331+
assert resp.status_code == 400
332+
333+
@responses.activate
334+
@patch(
335+
"sentry.integrations.bitbucket.integration.get_integration_from_jwt",
336+
side_effect=AtlassianConnectValidationError("Invalid JWT"),
337+
)
338+
def test_invalid_jwt(self, mock_verify: MagicMock) -> None:
339+
self._initialize_pipeline()
340+
resp = self._advance_step({"jwt": "invalid-token"})
341+
assert resp.status_code == 400
342+
assert "Unable to verify installation" in resp.data["jwt"][0]
343+
344+
@responses.activate
345+
@patch("sentry.integrations.bitbucket.integration.get_integration_from_jwt")
346+
def test_full_pipeline_flow(self, mock_verify: MagicMock) -> None:
347+
mock_verify.return_value = RpcIntegration(
348+
id=self.integration.id,
349+
provider=self.integration.provider,
350+
external_id=self.subject,
351+
name=self.integration.name,
352+
metadata=self.integration.metadata,
353+
status=self.integration.status,
354+
)
355+
356+
resp = self._initialize_pipeline()
357+
assert resp.data["step"] == "authorize"
358+
359+
resp = self._advance_step({"jwt": "valid-jwt-token"})
360+
assert resp.status_code == 200
361+
assert resp.data["status"] == "complete"
362+
assert "data" in resp.data
363+
364+
assert OrganizationIntegration.objects.filter(
365+
organization_id=self.organization.id,
366+
integration=self.integration,
367+
).exists()

0 commit comments

Comments
 (0)