Skip to content

Commit 6a322ee

Browse files
committed
feat(bitbucket-server): CODEOWNERS support and stacktrace linking
1 parent e868ee8 commit 6a322ee

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.services.integration.model import RpcIntegration
1211
from sentry.integrations.source_code_management.repository import RepositoryClient
@@ -29,6 +28,9 @@ class BitbucketServerAPIPath:
2928
repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits"
3029
commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes"
3130

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

3335
class BitbucketServerSetupClient(ApiClient):
3436
"""
@@ -255,9 +257,25 @@ def _get_values(self, uri, params, max_pages=1000000):
255257
return values
256258

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

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

src/sentry/integrations/bitbucket_server/integration.py

+49-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from typing import Any
4-
from urllib.parse import urlparse
4+
from urllib.parse import parse_qs, quote, urlencode, urlparse
55

66
from cryptography.hazmat.backends import default_backend
77
from cryptography.hazmat.primitives.serialization import load_pem_private_key
@@ -17,7 +17,6 @@
1717
from sentry.integrations.base import (
1818
FeatureDescription,
1919
IntegrationDomain,
20-
IntegrationFeatureNotImplementedError,
2120
IntegrationFeatures,
2221
IntegrationMetadata,
2322
IntegrationProvider,
@@ -60,6 +59,19 @@
6059
""",
6160
IntegrationFeatures.COMMITS,
6261
),
62+
FeatureDescription(
63+
"""
64+
Link your Sentry stack traces back to your Bitbucket source code with stack
65+
trace linking.
66+
""",
67+
IntegrationFeatures.STACKTRACE_LINK,
68+
),
69+
FeatureDescription(
70+
"""
71+
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.
72+
""",
73+
IntegrationFeatures.CODEOWNERS,
74+
),
6375
]
6476

6577
setup_alert = {
@@ -242,6 +254,8 @@ class BitbucketServerIntegration(RepositoryIntegration):
242254

243255
default_identity = None
244256

257+
codeowners_locations = [".bitbucket/CODEOWNERS"]
258+
245259
@property
246260
def integration_name(self) -> str:
247261
return "bitbucket_server"
@@ -308,16 +322,38 @@ def get_unmigratable_repositories(self):
308322
return list(filter(lambda repo: repo.name not in accessible_repos, repos))
309323

310324
def source_url_matches(self, url: str) -> bool:
311-
raise IntegrationFeatureNotImplementedError
325+
return url.startswith(self.model.metadata["base_url"])
312326

313327
def format_source_url(self, repo: Repository, filepath: str, branch: str | None) -> str:
314-
raise IntegrationFeatureNotImplementedError
328+
project = quote(repo.config["project"])
329+
repo_name = quote(repo.config["repo"])
330+
source_url = f"{self.model.metadata["base_url"]}/projects/{project}/repos/{repo_name}/browse/{filepath}"
331+
332+
if branch:
333+
source_url += "?" + urlencode({"at": branch})
334+
335+
return source_url
315336

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

319353
def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str:
320-
raise IntegrationFeatureNotImplementedError
354+
parsed_repo_url = urlparse(repo.url)
355+
parsed_url = urlparse(url)
356+
return parsed_url.path.replace(parsed_repo_url.path + "/", "")
321357

322358
# Bitbucket Server only methods
323359

@@ -332,7 +368,13 @@ class BitbucketServerIntegrationProvider(IntegrationProvider):
332368
metadata = metadata
333369
integration_cls = BitbucketServerIntegration
334370
needs_default_identity = True
335-
features = frozenset([IntegrationFeatures.COMMITS])
371+
features = frozenset(
372+
[
373+
IntegrationFeatures.COMMITS,
374+
IntegrationFeatures.STACKTRACE_LINK,
375+
IntegrationFeatures.CODEOWNERS,
376+
]
377+
)
336378
setup_dialog_config = {"width": 1030, "height": 1000}
337379

338380
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)