Skip to content

Commit f0f7151

Browse files
committed
feat(bitbucket-server): CODEOWNERS support and stacktrace linking
1 parent 624bcfb commit f0f7151

File tree

5 files changed

+359
-20
lines changed

5 files changed

+359
-20
lines changed

src/sentry/integrations/bitbucket_server/client.py

+21-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from requests_oauthlib import OAuth1
77

88
from sentry.identity.services.identity.model import RpcIdentity
9-
from sentry.integrations.base import IntegrationFeatureNotImplementedError
109
from sentry.integrations.client import ApiClient
1110
from sentry.integrations.models.integration import Integration
1211
from sentry.integrations.services.integration.model import RpcIntegration
@@ -30,6 +29,9 @@ class BitbucketServerAPIPath:
3029
repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits"
3130
commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes"
3231

32+
raw = "/projects/{project}/repos/{repo}/raw/{path}?at={sha}"
33+
source = "/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?at={sha}"
34+
3335

3436
class BitbucketServerSetupClient(ApiClient):
3537
"""
@@ -256,9 +258,25 @@ def _get_values(self, uri, params, max_pages=1000000):
256258
return values
257259

258260
def check_file(self, repo: Repository, path: str, version: str | None) -> object | None:
259-
raise IntegrationFeatureNotImplementedError
261+
return self.head_cached(
262+
path=BitbucketServerAPIPath.source.format(
263+
project=repo.config["project"],
264+
repo=repo.config["repo"],
265+
path=path,
266+
sha=version,
267+
),
268+
)
260269

261270
def get_file(
262271
self, repo: Repository, path: str, ref: str | None, codeowners: bool = False
263272
) -> str:
264-
raise IntegrationFeatureNotImplementedError
273+
response = self.get_cached(
274+
path=BitbucketServerAPIPath.raw.format(
275+
project=repo.config["project"],
276+
repo=repo.config["repo"],
277+
path=path,
278+
sha=ref,
279+
),
280+
raw_response=True,
281+
)
282+
return response.text

src/sentry/integrations/bitbucket_server/integration.py

+49-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Mapping
44
from typing import Any
5-
from urllib.parse import urlparse
5+
from urllib.parse import parse_qs, quote, urlencode, urlparse
66

77
from cryptography.hazmat.backends import default_backend
88
from cryptography.hazmat.primitives.serialization import load_pem_private_key
@@ -19,7 +19,6 @@
1919
FeatureDescription,
2020
IntegrationData,
2121
IntegrationDomain,
22-
IntegrationFeatureNotImplementedError,
2322
IntegrationFeatures,
2423
IntegrationMetadata,
2524
IntegrationProvider,
@@ -62,6 +61,19 @@
6261
""",
6362
IntegrationFeatures.COMMITS,
6463
),
64+
FeatureDescription(
65+
"""
66+
Link your Sentry stack traces back to your Bitbucket source code with stack
67+
trace linking.
68+
""",
69+
IntegrationFeatures.STACKTRACE_LINK,
70+
),
71+
FeatureDescription(
72+
"""
73+
Import your Bitbucket [CODEOWNERS file](https://support.atlassian.com/bitbucket-cloud/docs/set-up-and-use-code-owners/) and use it alongside your ownership rules to assign Sentry issues.
74+
""",
75+
IntegrationFeatures.CODEOWNERS,
76+
),
6577
]
6678

6779
setup_alert = {
@@ -246,6 +258,8 @@ class BitbucketServerIntegration(RepositoryIntegration):
246258

247259
default_identity = None
248260

261+
codeowners_locations = [".bitbucket/CODEOWNERS"]
262+
249263
@property
250264
def integration_name(self) -> str:
251265
return "bitbucket_server"
@@ -312,16 +326,38 @@ def get_unmigratable_repositories(self):
312326
return list(filter(lambda repo: repo.name not in accessible_repos, repos))
313327

314328
def source_url_matches(self, url: str) -> bool:
315-
raise IntegrationFeatureNotImplementedError
329+
return url.startswith(self.model.metadata["base_url"])
316330

317331
def format_source_url(self, repo: Repository, filepath: str, branch: str | None) -> str:
318-
raise IntegrationFeatureNotImplementedError
332+
project = quote(repo.config["project"])
333+
repo_name = quote(repo.config["repo"])
334+
source_url = f"{self.model.metadata["base_url"]}/projects/{project}/repos/{repo_name}/browse/{filepath}"
335+
336+
if branch:
337+
source_url += "?" + urlencode({"at": branch})
338+
339+
return source_url
319340

320341
def extract_branch_from_source_url(self, repo: Repository, url: str) -> str:
321-
raise IntegrationFeatureNotImplementedError
342+
parsed_url = urlparse(url)
343+
qs = parse_qs(parsed_url.query)
344+
345+
if "at" in qs and len(qs["at"]) == 1:
346+
branch = qs["at"][0]
347+
348+
# branch name may be prefixed with refs/heads/, so we strip that
349+
refs_prefix = "refs/heads/"
350+
if branch.startswith(refs_prefix):
351+
branch = branch[len(refs_prefix) :]
352+
353+
return branch
354+
355+
return ""
322356

323357
def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str:
324-
raise IntegrationFeatureNotImplementedError
358+
parsed_repo_url = urlparse(repo.url)
359+
parsed_url = urlparse(url)
360+
return parsed_url.path.replace(parsed_repo_url.path + "/", "")
325361

326362
# Bitbucket Server only methods
327363

@@ -336,7 +372,13 @@ class BitbucketServerIntegrationProvider(IntegrationProvider):
336372
metadata = metadata
337373
integration_cls = BitbucketServerIntegration
338374
needs_default_identity = True
339-
features = frozenset([IntegrationFeatures.COMMITS])
375+
features = frozenset(
376+
[
377+
IntegrationFeatures.COMMITS,
378+
IntegrationFeatures.STACKTRACE_LINK,
379+
IntegrationFeatures.CODEOWNERS,
380+
]
381+
)
340382
setup_dialog_config = {"width": 1030, "height": 1000}
341383

342384
def get_pipeline_views(self) -> list[PipelineView]:

src/sentry/models/commitfilechange.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@ def is_valid_type(value: str) -> bool:
4848

4949

5050
def process_resource_change(instance, **kwargs):
51+
from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration
5152
from sentry.integrations.github.integration import GitHubIntegration
5253
from sentry.integrations.gitlab.integration import GitlabIntegration
5354
from sentry.tasks.codeowners import code_owners_auto_sync
5455

5556
def _spawn_task():
56-
filepaths = set(GitHubIntegration.codeowners_locations) | set(
57-
GitlabIntegration.codeowners_locations
57+
filepaths = (
58+
set(GitHubIntegration.codeowners_locations)
59+
| set(GitlabIntegration.codeowners_locations)
60+
| set(BitbucketServerIntegration.codeowners_locations)
5861
)
5962

6063
# CODEOWNERS file added or modified, trigger auto-sync

tests/sentry/integrations/bitbucket_server/test_client.py

+155-7
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import orjson
2+
import pytest
23
import responses
34
from django.test import override_settings
45
from requests import Request
56

67
from fixtures.bitbucket_server import REPO
7-
from sentry.integrations.bitbucket_server.client import (
8-
BitbucketServerAPIPath,
9-
BitbucketServerClient,
10-
)
8+
from sentry.integrations.bitbucket_server.client import BitbucketServerAPIPath
9+
from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration
10+
from sentry.models.repository import Repository
11+
from sentry.shared_integrations.exceptions import ApiError
12+
from sentry.shared_integrations.response.base import BaseApiResponse
13+
from sentry.silo.base import SiloMode
1114
from sentry.testutils.cases import BaseTestCase, TestCase
12-
from sentry.testutils.silo import control_silo_test
15+
from sentry.testutils.helpers.integrations import get_installation_of_type
16+
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
1317
from tests.sentry.integrations.jira_server import EXAMPLE_PRIVATE_KEY
1418

1519
control_address = "http://controlserver"
1620
secret = "hush-hush-im-invisible"
1721

22+
BITBUCKET_SERVER_CODEOWNERS = {
23+
"filepath": ".bitbucket/CODEOWNERS",
24+
"html_url": "https://bitbucket.example.com/projects/PROJ/repos/repository-name/browse/.bitbucket/CODEOWNERS?at=master",
25+
"raw": "docs/* @jianyuan @getsentry/ecosystem\n* @jianyuan\n",
26+
}
27+
1828

1929
@override_settings(
2030
SENTRY_SUBNET_SECRET=secret,
@@ -44,8 +54,23 @@ def setUp(self):
4454
self.integration.add_organization(
4555
self.organization, self.user, default_auth_id=self.identity.id
4656
)
47-
self.install = self.integration.get_installation(self.organization.id)
48-
self.bb_server_client: BitbucketServerClient = self.install.get_client()
57+
self.install = get_installation_of_type(
58+
BitbucketServerIntegration, self.integration, self.organization.id
59+
)
60+
self.bb_server_client = self.install.get_client()
61+
62+
with assume_test_silo_mode(SiloMode.REGION):
63+
self.repo = Repository.objects.create(
64+
provider=self.integration.provider,
65+
name="PROJ/repository-name",
66+
organization_id=self.organization.id,
67+
config={
68+
"name": "TEST/repository-name",
69+
"project": "PROJ",
70+
"repo": "repository-name",
71+
},
72+
integration_id=self.integration.id,
73+
)
4974

5075
def test_authorize_request(self):
5176
method = "GET"
@@ -81,3 +106,126 @@ def test_get_repo_authentication(self):
81106

82107
assert len(responses.calls) == 1
83108
assert "oauth_consumer_key" in responses.calls[0].request.headers["Authorization"]
109+
110+
@responses.activate
111+
def test_check_file(self):
112+
path = "src/sentry/integrations/bitbucket_server/client.py"
113+
version = "master"
114+
url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format(
115+
project=self.repo.config["project"],
116+
repo=self.repo.config["repo"],
117+
path=path,
118+
sha=version,
119+
)
120+
121+
responses.add(
122+
responses.HEAD,
123+
url=url,
124+
status=200,
125+
)
126+
127+
resp = self.bb_server_client.check_file(self.repo, path, version)
128+
assert isinstance(resp, BaseApiResponse)
129+
assert resp.status_code == 200
130+
131+
@responses.activate
132+
def test_check_no_file(self):
133+
path = "src/santry/integrations/bitbucket_server/client.py"
134+
version = "master"
135+
url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format(
136+
project=self.repo.config["project"],
137+
repo=self.repo.config["repo"],
138+
path=path,
139+
sha=version,
140+
)
141+
142+
responses.add(
143+
responses.HEAD,
144+
url=url,
145+
status=404,
146+
)
147+
148+
with pytest.raises(ApiError):
149+
self.bb_server_client.check_file(self.repo, path, version)
150+
151+
@responses.activate
152+
def test_get_file(self):
153+
path = "src/sentry/integrations/bitbucket_server/client.py"
154+
version = "master"
155+
url = self.bb_server_client.base_url + BitbucketServerAPIPath.raw.format(
156+
project=self.repo.config["project"],
157+
repo=self.repo.config["repo"],
158+
path=path,
159+
sha=version,
160+
)
161+
162+
responses.add(
163+
responses.GET,
164+
url=url,
165+
body="Hello, world!",
166+
status=200,
167+
)
168+
169+
resp = self.bb_server_client.get_file(self.repo, path, version)
170+
assert resp == "Hello, world!"
171+
172+
@responses.activate
173+
def test_get_stacktrace_link(self):
174+
path = "src/sentry/integrations/bitbucket/client.py"
175+
version = "master"
176+
url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format(
177+
project=self.repo.config["project"],
178+
repo=self.repo.config["repo"],
179+
path=path,
180+
sha=version,
181+
)
182+
183+
responses.add(
184+
method=responses.HEAD,
185+
url=url,
186+
status=200,
187+
)
188+
189+
source_url = self.install.get_stacktrace_link(self.repo, path, "master", version)
190+
assert (
191+
source_url
192+
== "https://bitbucket.example.com/projects/PROJ/repos/repository-name/browse/src/sentry/integrations/bitbucket/client.py?at=master"
193+
)
194+
195+
@responses.activate
196+
def test_get_codeowner_file(self):
197+
self.config = self.create_code_mapping(
198+
repo=self.repo,
199+
project=self.project,
200+
)
201+
202+
path = ".bitbucket/CODEOWNERS"
203+
source_url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format(
204+
project=self.repo.config["project"],
205+
repo=self.repo.config["repo"],
206+
path=path,
207+
sha=self.config.default_branch,
208+
)
209+
raw_url = self.bb_server_client.base_url + BitbucketServerAPIPath.raw.format(
210+
project=self.repo.config["project"],
211+
repo=self.repo.config["repo"],
212+
path=path,
213+
sha=self.config.default_branch,
214+
)
215+
216+
responses.add(
217+
method=responses.HEAD,
218+
url=source_url,
219+
status=200,
220+
)
221+
responses.add(
222+
method=responses.GET,
223+
url=raw_url,
224+
content_type="text/plain",
225+
body=BITBUCKET_SERVER_CODEOWNERS["raw"],
226+
)
227+
228+
result = self.install.get_codeowner_file(
229+
self.config.repository, ref=self.config.default_branch
230+
)
231+
assert result == BITBUCKET_SERVER_CODEOWNERS

0 commit comments

Comments
 (0)