From 50a05a3221bb9038cbc38b02824e0e42d66fff78 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Fri, 7 Mar 2025 23:07:02 +0000 Subject: [PATCH 1/5] feat(bitbucket-server): CODEOWNERS support and stacktrace linking --- .../integrations/bitbucket_server/client.py | 24 ++- .../bitbucket_server/integration.py | 58 ++++++- src/sentry/models/commitfilechange.py | 2 + .../bitbucket_server/test_client.py | 162 +++++++++++++++++- .../bitbucket_server/test_integration.py | 130 +++++++++++++- 5 files changed, 358 insertions(+), 18 deletions(-) diff --git a/src/sentry/integrations/bitbucket_server/client.py b/src/sentry/integrations/bitbucket_server/client.py index 66f842f6bb8d2a..5b8041fe9c38cf 100644 --- a/src/sentry/integrations/bitbucket_server/client.py +++ b/src/sentry/integrations/bitbucket_server/client.py @@ -6,7 +6,6 @@ from requests_oauthlib import OAuth1 from sentry.identity.services.identity.model import RpcIdentity -from sentry.integrations.base import IntegrationFeatureNotImplementedError from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration @@ -30,6 +29,9 @@ class BitbucketServerAPIPath: repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits" commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes" + raw = "/projects/{project}/repos/{repo}/raw/{path}?at={sha}" + source = "/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?at={sha}" + class BitbucketServerSetupClient(ApiClient): """ @@ -256,9 +258,25 @@ def _get_values(self, uri, params, max_pages=1000000): return values def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: - raise IntegrationFeatureNotImplementedError + return self.head_cached( + path=BitbucketServerAPIPath.source.format( + project=repo.config["project"], + repo=repo.config["repo"], + path=path, + sha=version, + ), + ) def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False ) -> str: - raise IntegrationFeatureNotImplementedError + response = self.get_cached( + path=BitbucketServerAPIPath.raw.format( + project=repo.config["project"], + repo=repo.config["repo"], + path=path, + sha=ref, + ), + raw_response=True, + ) + return response.text diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 401798f65ddbb8..1144b33f3e2bf6 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from typing import Any -from urllib.parse import urlparse +from urllib.parse import parse_qs, quote, urlencode, urlparse from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_private_key @@ -19,7 +19,6 @@ FeatureDescription, IntegrationData, IntegrationDomain, - IntegrationFeatureNotImplementedError, IntegrationFeatures, IntegrationMetadata, IntegrationProvider, @@ -62,6 +61,19 @@ """, IntegrationFeatures.COMMITS, ), + FeatureDescription( + """ + Link your Sentry stack traces back to your Bitbucket source code with stack + trace linking. + """, + IntegrationFeatures.STACKTRACE_LINK, + ), + FeatureDescription( + """ + 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. + """, + IntegrationFeatures.CODEOWNERS, + ), ] setup_alert = { @@ -246,6 +258,8 @@ class BitbucketServerIntegration(RepositoryIntegration): default_identity = None + codeowners_locations = [".bitbucket/CODEOWNERS"] + @property def integration_name(self) -> str: return "bitbucket_server" @@ -312,16 +326,40 @@ def get_unmigratable_repositories(self): return list(filter(lambda repo: repo.name not in accessible_repos, repos)) def source_url_matches(self, url: str) -> bool: - raise IntegrationFeatureNotImplementedError + return url.startswith(self.model.metadata["base_url"]) def format_source_url(self, repo: Repository, filepath: str, branch: str | None) -> str: - raise IntegrationFeatureNotImplementedError + project = quote(repo.config["project"]) + repo_name = quote(repo.config["repo"]) + source_url = f"{self.model.metadata["base_url"]}/projects/{project}/repos/{repo_name}/browse/{filepath}" + + if branch: + source_url += "?" + urlencode({"at": branch}) + + return source_url def extract_branch_from_source_url(self, repo: Repository, url: str) -> str: - raise IntegrationFeatureNotImplementedError + parsed_url = urlparse(url) + qs = parse_qs(parsed_url.query) + + if "at" in qs and len(qs["at"]) == 1: + branch = qs["at"][0] + + # branch name may be prefixed with refs/heads/, so we strip that + refs_prefix = "refs/heads/" + if branch.startswith(refs_prefix): + branch = branch[len(refs_prefix) :] + + return branch + + return "" def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str: - raise IntegrationFeatureNotImplementedError + if repo.url is None: + return "" + parsed_repo_url = urlparse(repo.url) + parsed_url = urlparse(url) + return parsed_url.path.replace(parsed_repo_url.path + "/", "") # Bitbucket Server only methods @@ -336,7 +374,13 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): metadata = metadata integration_cls = BitbucketServerIntegration needs_default_identity = True - features = frozenset([IntegrationFeatures.COMMITS]) + features = frozenset( + [ + IntegrationFeatures.COMMITS, + IntegrationFeatures.STACKTRACE_LINK, + IntegrationFeatures.CODEOWNERS, + ] + ) setup_dialog_config = {"width": 1030, "height": 1000} def get_pipeline_views(self) -> list[PipelineView]: diff --git a/src/sentry/models/commitfilechange.py b/src/sentry/models/commitfilechange.py index 902424b93dc2ac..e24c867d1a6185 100644 --- a/src/sentry/models/commitfilechange.py +++ b/src/sentry/models/commitfilechange.py @@ -49,6 +49,7 @@ def is_valid_type(value: str) -> bool: def process_resource_change(instance, **kwargs): from sentry.integrations.bitbucket.integration import BitbucketIntegration + from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration from sentry.integrations.github.integration import GitHubIntegration from sentry.integrations.gitlab.integration import GitlabIntegration from sentry.tasks.codeowners import code_owners_auto_sync @@ -58,6 +59,7 @@ def _spawn_task(): set(GitHubIntegration.codeowners_locations) | set(GitlabIntegration.codeowners_locations) | set(BitbucketIntegration.codeowners_locations) + | set(BitbucketServerIntegration.codeowners_locations) ) # CODEOWNERS file added or modified, trigger auto-sync diff --git a/tests/sentry/integrations/bitbucket_server/test_client.py b/tests/sentry/integrations/bitbucket_server/test_client.py index dffeb270889b04..9c81a5641aefcb 100644 --- a/tests/sentry/integrations/bitbucket_server/test_client.py +++ b/tests/sentry/integrations/bitbucket_server/test_client.py @@ -1,20 +1,30 @@ import orjson +import pytest import responses from django.test import override_settings from requests import Request from fixtures.bitbucket_server import REPO -from sentry.integrations.bitbucket_server.client import ( - BitbucketServerAPIPath, - BitbucketServerClient, -) +from sentry.integrations.bitbucket_server.client import BitbucketServerAPIPath +from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration +from sentry.models.repository import Repository +from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response.base import BaseApiResponse +from sentry.silo.base import SiloMode from sentry.testutils.cases import BaseTestCase, TestCase -from sentry.testutils.silo import control_silo_test +from sentry.testutils.helpers.integrations import get_installation_of_type +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from tests.sentry.integrations.jira_server import EXAMPLE_PRIVATE_KEY control_address = "http://controlserver" secret = "hush-hush-im-invisible" +BITBUCKET_SERVER_CODEOWNERS = { + "filepath": ".bitbucket/CODEOWNERS", + "html_url": "https://bitbucket.example.com/projects/PROJ/repos/repository-name/browse/.bitbucket/CODEOWNERS?at=master", + "raw": "docs/* @jianyuan @getsentry/ecosystem\n* @jianyuan\n", +} + @override_settings( SENTRY_SUBNET_SECRET=secret, @@ -44,8 +54,23 @@ def setUp(self): self.integration.add_organization( self.organization, self.user, default_auth_id=self.identity.id ) - self.install = self.integration.get_installation(self.organization.id) - self.bb_server_client: BitbucketServerClient = self.install.get_client() + self.install = get_installation_of_type( + BitbucketServerIntegration, self.integration, self.organization.id + ) + self.bb_server_client = self.install.get_client() + + with assume_test_silo_mode(SiloMode.REGION): + self.repo = Repository.objects.create( + provider=self.integration.provider, + name="PROJ/repository-name", + organization_id=self.organization.id, + config={ + "name": "TEST/repository-name", + "project": "PROJ", + "repo": "repository-name", + }, + integration_id=self.integration.id, + ) def test_authorize_request(self): method = "GET" @@ -81,3 +106,126 @@ def test_get_repo_authentication(self): assert len(responses.calls) == 1 assert "oauth_consumer_key" in responses.calls[0].request.headers["Authorization"] + + @responses.activate + def test_check_file(self): + path = "src/sentry/integrations/bitbucket_server/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + responses.HEAD, + url=url, + status=200, + ) + + resp = self.bb_server_client.check_file(self.repo, path, version) + assert isinstance(resp, BaseApiResponse) + assert resp.status_code == 200 + + @responses.activate + def test_check_no_file(self): + path = "src/santry/integrations/bitbucket_server/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + responses.HEAD, + url=url, + status=404, + ) + + with pytest.raises(ApiError): + self.bb_server_client.check_file(self.repo, path, version) + + @responses.activate + def test_get_file(self): + path = "src/sentry/integrations/bitbucket_server/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.raw.format( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + responses.GET, + url=url, + body="Hello, world!", + status=200, + ) + + resp = self.bb_server_client.get_file(self.repo, path, version) + assert resp == "Hello, world!" + + @responses.activate + def test_get_stacktrace_link(self): + path = "src/sentry/integrations/bitbucket/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + method=responses.HEAD, + url=url, + status=200, + ) + + source_url = self.install.get_stacktrace_link(self.repo, path, "master", version) + assert ( + source_url + == "https://bitbucket.example.com/projects/PROJ/repos/repository-name/browse/src/sentry/integrations/bitbucket/client.py?at=master" + ) + + @responses.activate + def test_get_codeowner_file(self): + self.config = self.create_code_mapping( + repo=self.repo, + project=self.project, + ) + + path = ".bitbucket/CODEOWNERS" + source_url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=self.config.default_branch, + ) + raw_url = self.bb_server_client.base_url + BitbucketServerAPIPath.raw.format( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=self.config.default_branch, + ) + + responses.add( + method=responses.HEAD, + url=source_url, + status=200, + ) + responses.add( + method=responses.GET, + url=raw_url, + content_type="text/plain", + body=BITBUCKET_SERVER_CODEOWNERS["raw"], + ) + + result = self.install.get_codeowner_file( + self.config.repository, ref=self.config.default_branch + ) + assert result == BITBUCKET_SERVER_CODEOWNERS diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 1daf7db9af59e7..9c772d57e32874 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,3 +1,4 @@ +from functools import cached_property from unittest.mock import patch import responses @@ -7,9 +8,11 @@ from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import IntegrationTestCase -from sentry.testutils.silo import control_silo_test +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.identity import Identity, IdentityProvider @@ -17,6 +20,21 @@ class BitbucketServerIntegrationTest(IntegrationTestCase): provider = BitbucketServerIntegrationProvider + @cached_property + @assume_test_silo_mode(SiloMode.CONTROL) + def integration(self): + integration = Integration.objects.create( + provider=self.provider.key, + name="Bitbucket Server", + external_id="bitbucket_server:1", + metadata={ + "base_url": "https://bitbucket.example.com", + "domain_name": "bitbucket.example.com", + }, + ) + integration.add_organization(self.organization, self.user) + return integration + def test_config_view(self): resp = self.client.get(self.init_path) assert resp.status_code == 200 @@ -348,3 +366,113 @@ def test_setup_external_id_length(self): integration.external_id == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" ) + + def test_source_url_matches(self): + installation = self.integration.get_installation(self.organization.id) + + test_cases = [ + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + True, + ), + ( + "https://notbitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + False, + ), + ( + "https://jianyuan.io", + False, + ), + ] + + for source_url, matches in test_cases: + assert installation.source_url_matches(source_url) == matches + + def test_format_source_url(self): + installation = self.integration.get_installation(self.organization.id) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="TEST/sentry", + url="https://bitbucket.example.com/projects/TEST/repos/sentry/browse", + provider=self.provider.key, + external_id=123, + config={"name": "TEST/sentry", "project": "TEST", "repo": "sentry"}, + integration_id=self.integration.id, + ) + + assert ( + installation.format_source_url( + repo, "src/sentry/integrations/bitbucket_server/integration.py", None + ) + == "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py" + ) + assert ( + installation.format_source_url( + repo, "src/sentry/integrations/bitbucket_server/integration.py", "main" + ) + == "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main" + ) + + def test_extract_branch_from_source_url(self): + installation = self.integration.get_installation(self.organization.id) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="TEST/sentry", + url="https://bitbucket.example.com/projects/TEST/repos/sentry/browse", + provider=self.provider.key, + external_id=123, + config={"name": "TEST/sentry", "project": "TEST", "repo": "sentry"}, + integration_id=self.integration.id, + ) + + # ?at=main + assert ( + installation.extract_branch_from_source_url( + repo, + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + ) + == "main" + ) + # ?at=refs/heads/main + assert ( + installation.extract_branch_from_source_url( + repo, + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=refs%2Fheads%2Fmain", + ) + == "main" + ) + + def test_extract_source_path_from_source_url(self): + installation = self.integration.get_installation(self.organization.id) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="TEST/sentry", + url="https://bitbucket.example.com/projects/TEST/repos/sentry/browse", + provider=self.provider.key, + external_id=123, + config={"name": "TEST/sentry", "project": "TEST", "repo": "sentry"}, + integration_id=self.integration.id, + ) + + test_cases = [ + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py", + "src/sentry/integrations/bitbucket_server/integration.py", + ), + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + "src/sentry/integrations/bitbucket_server/integration.py", + ), + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=refs%2Fheads%2Fmain", + "src/sentry/integrations/bitbucket_server/integration.py", + ), + ] + for source_url, expected in test_cases: + assert installation.extract_source_path_from_source_url(repo, source_url) == expected From aec7b6db74258645a5c1ca404ba0b7a9b07e34f3 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 11 Mar 2025 22:46:59 +0000 Subject: [PATCH 2/5] Update src/sentry/integrations/bitbucket_server/integration.py Co-authored-by: Cathy Teng <70817427+cathteng@users.noreply.github.com> --- src/sentry/integrations/bitbucket_server/integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 1144b33f3e2bf6..7e131a9b8ed1ba 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -70,7 +70,7 @@ ), FeatureDescription( """ - 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. + Import your Bitbucket Server [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. """, IntegrationFeatures.CODEOWNERS, ), From 013eb498089330eb37c8b3cd62cce69b0f89570c Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 11 Mar 2025 22:47:08 +0000 Subject: [PATCH 3/5] Update src/sentry/integrations/bitbucket_server/integration.py Co-authored-by: Cathy Teng <70817427+cathteng@users.noreply.github.com> --- src/sentry/integrations/bitbucket_server/integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 7e131a9b8ed1ba..4a23cbd9eda999 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -63,7 +63,7 @@ ), FeatureDescription( """ - Link your Sentry stack traces back to your Bitbucket source code with stack + Link your Sentry stack traces back to your Bitbucket Server source code with stack trace linking. """, IntegrationFeatures.STACKTRACE_LINK, From 3f9dc48626c72f23e3d6f93969b3b674b2827cee Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 23 Mar 2025 12:49:58 +0000 Subject: [PATCH 4/5] ref: BitbucketServerAPIPath --- .../integrations/bitbucket_server/client.py | 22 ++--------- .../integrations/bitbucket_server/utils.py | 37 +++++++++++++++++++ .../bitbucket_server/test_client.py | 14 +++---- 3 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 src/sentry/integrations/bitbucket_server/utils.py diff --git a/src/sentry/integrations/bitbucket_server/client.py b/src/sentry/integrations/bitbucket_server/client.py index 5b8041fe9c38cf..db98fc7bee89be 100644 --- a/src/sentry/integrations/bitbucket_server/client.py +++ b/src/sentry/integrations/bitbucket_server/client.py @@ -6,6 +6,7 @@ from requests_oauthlib import OAuth1 from sentry.identity.services.identity.model import RpcIdentity +from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration @@ -16,23 +17,6 @@ logger = logging.getLogger("sentry.integrations.bitbucket_server") -class BitbucketServerAPIPath: - """ - project is the short key of the project - repo is the fully qualified slug - """ - - repository = "/rest/api/1.0/projects/{project}/repos/{repo}" - repositories = "/rest/api/1.0/repos" - repository_hook = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks/{id}" - repository_hooks = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks" - repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits" - commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes" - - raw = "/projects/{project}/repos/{repo}/raw/{path}?at={sha}" - source = "/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?at={sha}" - - class BitbucketServerSetupClient(ApiClient): """ Client for making requests to Bitbucket Server to follow OAuth1 flow. @@ -259,7 +243,7 @@ def _get_values(self, uri, params, max_pages=1000000): def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: return self.head_cached( - path=BitbucketServerAPIPath.source.format( + path=BitbucketServerAPIPath.build_source( project=repo.config["project"], repo=repo.config["repo"], path=path, @@ -271,7 +255,7 @@ def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False ) -> str: response = self.get_cached( - path=BitbucketServerAPIPath.raw.format( + path=BitbucketServerAPIPath.build_raw( project=repo.config["project"], repo=repo.config["repo"], path=path, diff --git a/src/sentry/integrations/bitbucket_server/utils.py b/src/sentry/integrations/bitbucket_server/utils.py new file mode 100644 index 00000000000000..79c66bfab7d12c --- /dev/null +++ b/src/sentry/integrations/bitbucket_server/utils.py @@ -0,0 +1,37 @@ +from urllib.parse import quote, urlencode + + +class BitbucketServerAPIPath: + """ + project is the short key of the project + repo is the fully qualified slug + """ + + repository = "/rest/api/1.0/projects/{project}/repos/{repo}" + repositories = "/rest/api/1.0/repos" + repository_hook = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks/{id}" + repository_hooks = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks" + repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits" + commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes" + + @staticmethod + def build_raw(project: str, repo: str, path: str, sha: str) -> str: + project = quote(project) + repo = quote(repo) + + params = {} + if sha: + params["at"] = sha + + return f"/projects/{project}/repos/{repo}/raw/{path}?{urlencode(params)}" + + @staticmethod + def build_source(project: str, repo: str, path: str, sha: str) -> str: + project = quote(project) + repo = quote(repo) + + params = {} + if sha: + params["at"] = sha + + return f"/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?{urlencode(params)}" diff --git a/tests/sentry/integrations/bitbucket_server/test_client.py b/tests/sentry/integrations/bitbucket_server/test_client.py index 9c81a5641aefcb..db1646b89d477d 100644 --- a/tests/sentry/integrations/bitbucket_server/test_client.py +++ b/tests/sentry/integrations/bitbucket_server/test_client.py @@ -5,8 +5,8 @@ from requests import Request from fixtures.bitbucket_server import REPO -from sentry.integrations.bitbucket_server.client import BitbucketServerAPIPath from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration +from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ApiError from sentry.shared_integrations.response.base import BaseApiResponse @@ -111,7 +111,7 @@ def test_get_repo_authentication(self): def test_check_file(self): path = "src/sentry/integrations/bitbucket_server/client.py" version = "master" - url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( project=self.repo.config["project"], repo=self.repo.config["repo"], path=path, @@ -132,7 +132,7 @@ def test_check_file(self): def test_check_no_file(self): path = "src/santry/integrations/bitbucket_server/client.py" version = "master" - url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( project=self.repo.config["project"], repo=self.repo.config["repo"], path=path, @@ -152,7 +152,7 @@ def test_check_no_file(self): def test_get_file(self): path = "src/sentry/integrations/bitbucket_server/client.py" version = "master" - url = self.bb_server_client.base_url + BitbucketServerAPIPath.raw.format( + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_raw( project=self.repo.config["project"], repo=self.repo.config["repo"], path=path, @@ -173,7 +173,7 @@ def test_get_file(self): def test_get_stacktrace_link(self): path = "src/sentry/integrations/bitbucket/client.py" version = "master" - url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( project=self.repo.config["project"], repo=self.repo.config["repo"], path=path, @@ -200,13 +200,13 @@ def test_get_codeowner_file(self): ) path = ".bitbucket/CODEOWNERS" - source_url = self.bb_server_client.base_url + BitbucketServerAPIPath.source.format( + source_url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( project=self.repo.config["project"], repo=self.repo.config["repo"], path=path, sha=self.config.default_branch, ) - raw_url = self.bb_server_client.base_url + BitbucketServerAPIPath.raw.format( + raw_url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_raw( project=self.repo.config["project"], repo=self.repo.config["repo"], path=path, From a5c33656a11980128f66f54d6b69cd25b469bc03 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Fri, 21 Mar 2025 13:51:38 +0000 Subject: [PATCH 5/5] feat(bitbucket-server): Commit context --- .../integrations/bitbucket_server/blame.py | 119 ++++++++ .../integrations/bitbucket_server/client.py | 37 ++- .../bitbucket_server/integration.py | 3 +- src/sentry/tasks/post_process.py | 7 +- .../bitbucket_server/test_client.py | 283 ++++++++++++++++++ 5 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 src/sentry/integrations/bitbucket_server/blame.py diff --git a/src/sentry/integrations/bitbucket_server/blame.py b/src/sentry/integrations/bitbucket_server/blame.py new file mode 100644 index 00000000000000..339e1739f8fa8a --- /dev/null +++ b/src/sentry/integrations/bitbucket_server/blame.py @@ -0,0 +1,119 @@ +import logging +from collections.abc import Mapping, Sequence +from dataclasses import asdict +from datetime import datetime, timezone +from typing import Any + +from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath +from sentry.integrations.source_code_management.commit_context import ( + CommitInfo, + FileBlameInfo, + SourceLineInfo, +) +from sentry.shared_integrations.client.base import BaseApiClient +from sentry.shared_integrations.exceptions import ApiError + +logger = logging.getLogger("sentry.integrations.bitbucket_server") + + +def _blame_file( + client: BaseApiClient, file: SourceLineInfo, extra: Mapping[str, Any] +) -> FileBlameInfo | None: + if file.lineno is None: + logger.warning("blame_file.no_lineno", extra=extra) + return None + + project = file.repo.config["project"] + repo = file.repo.config["repo"] + + browse_url = BitbucketServerAPIPath.get_browse( + project=project, + repo=repo, + path=file.path, + sha=file.ref, + blame=True, + no_content=True, + ) + + try: + data = client.get(browse_url) + except ApiError as e: + if e.code in (401, 403, 404): + logger.warning( + "blame_file.browse.api_error", + extra={ + **extra, + "code": e.code, + "error_message": e.text, + }, + ) + return None + raise + + for entry in data: + start = entry["lineNumber"] + span = entry["spannedLines"] + end = start + span - 1 # inclusive range + + if start <= file.lineno <= end: + commit_id = entry["commitId"] + commited_date = datetime.fromtimestamp( + entry["committerTimestamp"] / 1000.0, tz=timezone.utc + ) + + try: + commit_data = client.get_cached( + BitbucketServerAPIPath.repository_commit.format( + project=project, repo=repo, commit=commit_id + ), + ) + except ApiError as e: + logger.warning( + "blame_file.commit.api_error", + extra={ + **extra, + "code": e.code, + "error_message": e.text, + "commit_id": commit_id, + }, + ) + commit_message = None + else: + commit_message = commit_data.get("message") + + return FileBlameInfo( + **asdict(file), + commit=CommitInfo( + commitId=commit_id, + committedDate=commited_date, + commitMessage=commit_message, + commitAuthorName=entry["author"].get("name"), + commitAuthorEmail=entry["author"].get("emailAddress"), + ), + ) + + return None + + +def fetch_file_blames( + client: BaseApiClient, files: Sequence[SourceLineInfo], extra: Mapping[str, Any] +) -> list[FileBlameInfo]: + blames = [] + for file in files: + extra_file = { + **extra, + "repo_name": file.repo.name, + "file_path": file.path, + "branch_name": file.ref, + "file_lineno": file.lineno, + } + + blame = _blame_file(client, file, extra_file) + if blame: + blames.append(blame) + else: + logger.warning( + "fetch_file_blames.no_blame", + extra=extra_file, + ) + return blames diff --git a/src/sentry/integrations/bitbucket_server/client.py b/src/sentry/integrations/bitbucket_server/client.py index db98fc7bee89be..52629ed46783ae 100644 --- a/src/sentry/integrations/bitbucket_server/client.py +++ b/src/sentry/integrations/bitbucket_server/client.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Mapping, Sequence +from typing import Any from urllib.parse import parse_qsl from oauthlib.oauth1 import SIGNATURE_RSA @@ -6,13 +8,21 @@ from requests_oauthlib import OAuth1 from sentry.identity.services.identity.model import RpcIdentity +from sentry.integrations.base import IntegrationFeatureNotImplementedError +from sentry.integrations.bitbucket_server.blame import fetch_file_blames from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.source_code_management.commit_context import ( + CommitContextClient, + FileBlameInfo, + SourceLineInfo, +) from sentry.integrations.source_code_management.repository import RepositoryClient from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ApiError +from sentry.utils import metrics logger = logging.getLogger("sentry.integrations.bitbucket_server") @@ -86,7 +96,7 @@ def request(self, *args, **kwargs): return self._request(*args, **kwargs) -class BitbucketServerClient(ApiClient, RepositoryClient): +class BitbucketServerClient(ApiClient, RepositoryClient, CommitContextClient): """ Contains the BitBucket Server specifics in order to communicate with bitbucket @@ -264,3 +274,28 @@ def get_file( raw_response=True, ) return response.text + + def get_blame_for_files( + self, files: Sequence[SourceLineInfo], extra: Mapping[str, Any] + ) -> list[FileBlameInfo]: + metrics.incr("integrations.bitbucket_server.get_blame_for_files") + return fetch_file_blames( + self, + files, + extra={ + **extra, + "provider": "bitbucket_server", + "org_integration_id": self.integration_id, + }, + ) + + def create_comment(self, repo: str, issue_id: str, data: Mapping[str, Any]) -> Any: + raise IntegrationFeatureNotImplementedError + + def update_comment( + self, repo: str, issue_id: str, comment_id: str, data: Mapping[str, Any] + ) -> Any: + raise IntegrationFeatureNotImplementedError + + def get_merge_commit_sha_from_commit(self, repo: str, sha: str) -> str | None: + raise IntegrationFeatureNotImplementedError diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 4a23cbd9eda999..fa20d1cbbb82b3 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -26,6 +26,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.services.repository import repository_service from sentry.integrations.services.repository.model import RpcRepository +from sentry.integrations.source_code_management.commit_context import CommitContextIntegration from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.integrations.tasks.migrate_repo import migrate_repo from sentry.integrations.utils.metrics import ( @@ -251,7 +252,7 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase ) -class BitbucketServerIntegration(RepositoryIntegration): +class BitbucketServerIntegration(RepositoryIntegration, CommitContextIntegration): """ IntegrationInstallation implementation for Bitbucket Server """ diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 59493e52eb6c6e..26ddc0b56f6b09 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1081,7 +1081,12 @@ def process_commits(job: PostProcessJob) -> None: org_integrations = integration_service.get_organization_integrations( organization_id=event.project.organization_id, - providers=["github", "gitlab", "github_enterprise"], + providers=[ + "github", + "gitlab", + "github_enterprise", + "bitbucket_server", + ], ) has_integrations = len(org_integrations) > 0 # Cache the integrations check for 4 hours diff --git a/tests/sentry/integrations/bitbucket_server/test_client.py b/tests/sentry/integrations/bitbucket_server/test_client.py index db1646b89d477d..0f5d3e3a8eeacc 100644 --- a/tests/sentry/integrations/bitbucket_server/test_client.py +++ b/tests/sentry/integrations/bitbucket_server/test_client.py @@ -1,3 +1,9 @@ +from copy import deepcopy +from dataclasses import asdict +from datetime import datetime, timezone +from typing import Any +from unittest import mock + import orjson import pytest import responses @@ -7,6 +13,11 @@ from fixtures.bitbucket_server import REPO from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath +from sentry.integrations.source_code_management.commit_context import ( + CommitInfo, + FileBlameInfo, + SourceLineInfo, +) from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ApiError from sentry.shared_integrations.response.base import BaseApiResponse @@ -229,3 +240,275 @@ def test_get_codeowner_file(self): self.config.repository, ref=self.config.default_branch ) assert result == BITBUCKET_SERVER_CODEOWNERS + + +@control_silo_test +class BitbucketServerClientBlameForFilesTest(BitbucketServerClientTest): + def setUp(self): + super().setUp() + + self.file_1 = SourceLineInfo( + path="example_1.txt", + lineno=1, + ref="master", + repo=self.repo, + code_mapping=mock.ANY, + ) + self.file_2 = SourceLineInfo( + path="example_2.txt", + lineno=3, + ref="master", + repo=self.repo, + code_mapping=mock.ANY, + ) + self.file_3 = SourceLineInfo( + path="example_3.txt", + lineno=5, + ref="master", + repo=self.repo, + code_mapping=mock.ANY, + ) + + self.blame_1 = FileBlameInfo( + **asdict(self.file_1), + commit=CommitInfo( + commitId="first", + commitMessage="first commit message", + committedDate=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + commitAuthorEmail="first@user.com", + commitAuthorName="First User", + ), + ) + self.blame_2 = FileBlameInfo( + **asdict(self.file_2), + commit=CommitInfo( + commitId="second", + commitMessage="second commit message", + committedDate=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + commitAuthorEmail="second@user.com", + commitAuthorName="Second User", + ), + ) + self.blame_3 = FileBlameInfo( + **asdict(self.file_3), + commit=CommitInfo( + commitId="third", + commitMessage="third commit message", + committedDate=datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + commitAuthorEmail="third@user.com", + commitAuthorName="Third User", + ), + ) + + def set_up_success_blame_responses(self): + responses.add( + responses.GET, + url=self.make_blame_url(self.file_1), + json=self.make_blame_response(path="example_1.txt"), + status=200, + ) + responses.add( + responses.GET, + url=self.make_blame_url(self.file_2), + json=self.make_blame_response(path="example_2.txt"), + status=200, + ) + responses.add( + responses.GET, + url=self.make_blame_url(self.file_3), + json=self.make_blame_response(path="example_3.txt"), + status=200, + ) + + def set_up_success_commit_responses(self): + responses.add( + responses.GET, + url=self.make_commit_url(self.file_1, commit="first"), + json=self.make_commit_response(message="first commit message"), + status=200, + ) + responses.add( + responses.GET, + url=self.make_commit_url(self.file_2, commit="second"), + json=self.make_commit_response(message="second commit message"), + status=200, + ) + responses.add( + responses.GET, + url=self.make_commit_url(self.file_3, commit="third"), + json=self.make_commit_response(message="third commit message"), + status=200, + ) + + def make_blame_url(self, file: SourceLineInfo) -> str: + return f"{self.bb_server_client.base_url}{BitbucketServerAPIPath.get_browse( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=file.path, + sha=file.ref, + blame=True, + no_content=True, + )}" + + def make_blame_response(self, path: str) -> list[dict[str, Any]]: + return [ + { + "author": { + "name": "First User", + "emailAddress": "first@user.com", + }, + "authorTimestamp": 1735689600000, + "committer": { + "name": "First User", + "emailAddress": "first@user.com", + }, + "committerTimestamp": 1735689600000, + "commitHash": "first", + "displayCommitHash": "first", + "commitId": "first", + "commitDisplayId": "first", + "fileName": path, + "lineNumber": 1, + "spannedLines": 2, + }, + { + "author": { + "name": "Second User", + "emailAddress": "second@user.com", + }, + "authorTimestamp": 1735689600000, + "committer": { + "name": "Second User", + "emailAddress": "second@user.com", + }, + "committerTimestamp": 1735689600000, + "commitHash": "second", + "displayCommitHash": "second", + "commitId": "second", + "commitDisplayId": "second", + "fileName": path, + "lineNumber": 3, + "spannedLines": 2, + }, + { + "author": { + "name": "Third User", + "emailAddress": "third@user.com", + }, + "authorTimestamp": 1735689600000, + "committer": { + "name": "Third User", + "emailAddress": "third@user.com", + }, + "committerTimestamp": 1735689600000, + "commitHash": "third", + "displayCommitHash": "third", + "commitId": "third", + "commitDisplayId": "third", + "fileName": path, + "lineNumber": 5, + "spannedLines": 2, + }, + ] + + def make_commit_url(self, file: SourceLineInfo, commit: str) -> str: + return f"{self.bb_server_client.base_url}{BitbucketServerAPIPath.repository_commit.format( + project=file.repo.config["project"], + repo=file.repo.config["repo"], + commit=commit, + )}" + + def make_commit_response(self, message: str) -> dict: + return { + "message": message, + } + + @responses.activate + def test_success_single_file(self): + self.set_up_success_blame_responses() + self.set_up_success_commit_responses() + + resp = self.bb_server_client.get_blame_for_files(files=[self.file_1], extra={}) + + assert resp == [self.blame_1] + + @responses.activate + def test_success_multiple_files(self): + self.set_up_success_blame_responses() + self.set_up_success_commit_responses() + + resp = self.bb_server_client.get_blame_for_files( + files=[self.file_1, self.file_2, self.file_3], extra={} + ) + + assert resp == [self.blame_1, self.blame_2, self.blame_3] + + @mock.patch( + "sentry.integrations.bitbucket_server.blame.logger.warning", + ) + @responses.activate + def test_failure_blame_404(self, mock_logger_warning): + responses.add( + responses.GET, self.make_blame_url(self.file_1), status=404, body="No file found" + ) + + resp = self.bb_server_client.get_blame_for_files(files=[self.file_1], extra={}) + + assert resp == [] + mock_logger_warning.assert_any_call( + "blame_file.browse.api_error", + extra={ + "provider": "bitbucket_server", + "org_integration_id": self.bb_server_client.integration_id, + "code": 404, + "error_message": "No file found", + "repo_name": self.repo.name, + "file_path": self.file_1.path, + "branch_name": self.file_1.ref, + "file_lineno": self.file_1.lineno, + }, + ) + mock_logger_warning.assert_any_call( + "fetch_file_blames.no_blame", + extra={ + "provider": "bitbucket_server", + "org_integration_id": self.bb_server_client.integration_id, + "repo_name": self.repo.name, + "file_path": self.file_1.path, + "branch_name": self.file_1.ref, + "file_lineno": self.file_1.lineno, + }, + ) + + @mock.patch( + "sentry.integrations.bitbucket_server.blame.logger.warning", + ) + @responses.activate + def test_success_commit_404(self, mock_logger_warning): + self.set_up_success_blame_responses() + responses.add( + responses.GET, + self.make_commit_url(self.file_1, commit="first"), + status=404, + body="No file found", + ) + + resp = self.bb_server_client.get_blame_for_files(files=[self.file_1], extra={}) + + blame = deepcopy(self.blame_1) + blame.commit.commitMessage = None + assert resp == [blame] + mock_logger_warning.assert_any_call( + "blame_file.commit.api_error", + extra={ + "provider": "bitbucket_server", + "org_integration_id": self.bb_server_client.integration_id, + "code": 404, + "error_message": "No file found", + "commit_id": "first", + "repo_name": self.repo.name, + "file_path": self.file_1.path, + "branch_name": self.file_1.ref, + "file_lineno": self.file_1.lineno, + }, + )