Skip to content

Commit f222cc0

Browse files
committed
feat(bitbucket-server): Commit context
1 parent dfa4ed5 commit f222cc0

File tree

6 files changed

+494
-27
lines changed

6 files changed

+494
-27
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import logging
2+
from collections.abc import Mapping, Sequence
3+
from dataclasses import asdict
4+
from datetime import datetime, timezone
5+
from typing import Any
6+
7+
from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath
8+
from sentry.integrations.source_code_management.commit_context import (
9+
CommitInfo,
10+
FileBlameInfo,
11+
SourceLineInfo,
12+
)
13+
from sentry.shared_integrations.client.base import BaseApiClient
14+
from sentry.shared_integrations.exceptions import ApiError
15+
16+
logger = logging.getLogger("sentry.integrations.bitbucket_server")
17+
18+
19+
def _blame_file(
20+
client: BaseApiClient, file: SourceLineInfo, extra: Mapping[str, Any]
21+
) -> FileBlameInfo | None:
22+
if file.lineno is None:
23+
logger.warning("blame_file.no_lineno", extra=extra)
24+
return None
25+
26+
project = file.repo.config["project"]
27+
repo = file.repo.config["repo"]
28+
29+
browse_url = BitbucketServerAPIPath.get_browse(
30+
project=project,
31+
repo=repo,
32+
path=file.path,
33+
sha=file.ref,
34+
blame=True,
35+
no_content=True,
36+
)
37+
38+
try:
39+
data = client.get(browse_url)
40+
except ApiError as e:
41+
if e.code in (401, 403, 404):
42+
logger.warning(
43+
"blame_file.browse.api_error",
44+
extra={
45+
**extra,
46+
"code": e.code,
47+
"error_message": e.text,
48+
},
49+
)
50+
return None
51+
raise
52+
53+
for entry in data:
54+
start = entry["lineNumber"]
55+
span = entry["spannedLines"]
56+
end = start + span - 1 # inclusive range
57+
58+
if start <= file.lineno <= end:
59+
commit_id = entry["commitId"]
60+
commited_date = datetime.fromtimestamp(
61+
entry["committerTimestamp"] / 1000.0, tz=timezone.utc
62+
)
63+
64+
try:
65+
commit_data = client.get_cached(
66+
BitbucketServerAPIPath.repository_commit.format(
67+
project=project, repo=repo, commit=commit_id
68+
),
69+
)
70+
except ApiError as e:
71+
logger.warning(
72+
"blame_file.commit.api_error",
73+
extra={
74+
**extra,
75+
"code": e.code,
76+
"error_message": e.text,
77+
"commit_id": commit_id,
78+
},
79+
)
80+
commit_message = None
81+
else:
82+
commit_message = commit_data.get("message")
83+
84+
return FileBlameInfo(
85+
**asdict(file),
86+
commit=CommitInfo(
87+
commitId=commit_id,
88+
committedDate=commited_date,
89+
commitMessage=commit_message,
90+
commitAuthorName=entry["author"].get("name"),
91+
commitAuthorEmail=entry["author"].get("emailAddress"),
92+
),
93+
)
94+
95+
return None
96+
97+
98+
def fetch_file_blames(
99+
client: BaseApiClient, files: Sequence[SourceLineInfo], extra: Mapping[str, Any]
100+
) -> list[FileBlameInfo]:
101+
blames = []
102+
for file in files:
103+
extra_file = {
104+
**extra,
105+
"repo_name": file.repo.name,
106+
"file_path": file.path,
107+
"branch_name": file.ref,
108+
"file_lineno": file.lineno,
109+
}
110+
111+
blame = _blame_file(client, file, extra_file)
112+
if blame:
113+
blames.append(blame)
114+
else:
115+
logger.warning(
116+
"fetch_file_blames.no_blame",
117+
extra=extra_file,
118+
)
119+
return blames

src/sentry/integrations/bitbucket_server/client.py

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,32 @@
11
import logging
2+
from collections.abc import Mapping, Sequence
3+
from typing import Any
24
from urllib.parse import parse_qsl
35

46
from oauthlib.oauth1 import SIGNATURE_RSA
57
from requests import PreparedRequest
68
from requests_oauthlib import OAuth1
79

810
from sentry.identity.services.identity.model import RpcIdentity
11+
from sentry.integrations.base import IntegrationFeatureNotImplementedError
12+
from sentry.integrations.bitbucket_server.blame import fetch_file_blames
13+
from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath
914
from sentry.integrations.client import ApiClient
1015
from sentry.integrations.models.integration import Integration
1116
from sentry.integrations.services.integration.model import RpcIntegration
17+
from sentry.integrations.source_code_management.commit_context import (
18+
CommitContextClient,
19+
FileBlameInfo,
20+
SourceLineInfo,
21+
)
1222
from sentry.integrations.source_code_management.repository import RepositoryClient
1323
from sentry.models.repository import Repository
1424
from sentry.shared_integrations.exceptions import ApiError
25+
from sentry.utils import metrics
1526

1627
logger = logging.getLogger("sentry.integrations.bitbucket_server")
1728

1829

19-
class BitbucketServerAPIPath:
20-
"""
21-
project is the short key of the project
22-
repo is the fully qualified slug
23-
"""
24-
25-
repository = "/rest/api/1.0/projects/{project}/repos/{repo}"
26-
repositories = "/rest/api/1.0/repos"
27-
repository_hook = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks/{id}"
28-
repository_hooks = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks"
29-
repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits"
30-
commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes"
31-
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-
35-
3630
class BitbucketServerSetupClient(ApiClient):
3731
"""
3832
Client for making requests to Bitbucket Server to follow OAuth1 flow.
@@ -102,7 +96,7 @@ def request(self, *args, **kwargs):
10296
return self._request(*args, **kwargs)
10397

10498

105-
class BitbucketServerClient(ApiClient, RepositoryClient):
99+
class BitbucketServerClient(ApiClient, RepositoryClient, CommitContextClient):
106100
"""
107101
Contains the BitBucket Server specifics in order to communicate with bitbucket
108102
@@ -259,7 +253,7 @@ def _get_values(self, uri, params, max_pages=1000000):
259253

260254
def check_file(self, repo: Repository, path: str, version: str | None) -> object | None:
261255
return self.head_cached(
262-
path=BitbucketServerAPIPath.source.format(
256+
path=BitbucketServerAPIPath.get_browse(
263257
project=repo.config["project"],
264258
repo=repo.config["repo"],
265259
path=path,
@@ -280,3 +274,28 @@ def get_file(
280274
raw_response=True,
281275
)
282276
return response.text
277+
278+
def get_blame_for_files(
279+
self, files: Sequence[SourceLineInfo], extra: Mapping[str, Any]
280+
) -> list[FileBlameInfo]:
281+
metrics.incr("integrations.bitbucket_server.get_blame_for_files")
282+
return fetch_file_blames(
283+
self,
284+
files,
285+
extra={
286+
**extra,
287+
"provider": "bitbucket_server",
288+
"org_integration_id": self.integration_id,
289+
},
290+
)
291+
292+
def create_comment(self, repo: str, issue_id: str, data: Mapping[str, Any]) -> Any:
293+
raise IntegrationFeatureNotImplementedError
294+
295+
def update_comment(
296+
self, repo: str, issue_id: str, comment_id: str, data: Mapping[str, Any]
297+
) -> Any:
298+
raise IntegrationFeatureNotImplementedError
299+
300+
def get_merge_commit_sha_from_commit(self, repo: str, sha: str) -> str | None:
301+
raise IntegrationFeatureNotImplementedError

src/sentry/integrations/bitbucket_server/integration.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from sentry.integrations.models.integration import Integration
2727
from sentry.integrations.services.repository import repository_service
2828
from sentry.integrations.services.repository.model import RpcRepository
29+
from sentry.integrations.source_code_management.commit_context import CommitContextIntegration
2930
from sentry.integrations.source_code_management.repository import RepositoryIntegration
3031
from sentry.integrations.tasks.migrate_repo import migrate_repo
3132
from sentry.integrations.utils.metrics import (
@@ -63,7 +64,7 @@
6364
),
6465
FeatureDescription(
6566
"""
66-
Link your Sentry stack traces back to your Bitbucket source code with stack
67+
Link your Sentry stack traces back to your Bitbucket Server source code with stack
6768
trace linking.
6869
""",
6970
IntegrationFeatures.STACKTRACE_LINK,
@@ -251,7 +252,7 @@ def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase
251252
)
252253

253254

254-
class BitbucketServerIntegration(RepositoryIntegration):
255+
class BitbucketServerIntegration(RepositoryIntegration, CommitContextIntegration):
255256
"""
256257
IntegrationInstallation implementation for Bitbucket Server
257258
"""
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from urllib.parse import quote, urlencode
2+
3+
4+
class BitbucketServerAPIPath:
5+
"""
6+
project is the short key of the project
7+
repo is the fully qualified slug
8+
"""
9+
10+
repository = "/rest/api/1.0/projects/{project}/repos/{repo}"
11+
repositories = "/rest/api/1.0/repos"
12+
repository_hook = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks/{id}"
13+
repository_hooks = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks"
14+
repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits"
15+
repository_commit = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}"
16+
commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes"
17+
raw = "/projects/{project}/repos/{repo}/raw/{path}?at={sha}"
18+
source = "/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?at={sha}"
19+
20+
@staticmethod
21+
def get_browse(
22+
project: str,
23+
repo: str,
24+
path: str,
25+
sha: str | None,
26+
blame: bool = False,
27+
no_content: bool = False,
28+
) -> str:
29+
project = quote(project)
30+
repo = quote(repo)
31+
32+
params = {}
33+
if sha:
34+
params["at"] = sha
35+
if blame:
36+
params["blame"] = "true"
37+
if no_content:
38+
params["noContent"] = "true"
39+
40+
return f"/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?{urlencode(params)}"

src/sentry/tasks/post_process.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1076,7 +1076,12 @@ def process_commits(job: PostProcessJob) -> None:
10761076

10771077
org_integrations = integration_service.get_organization_integrations(
10781078
organization_id=event.project.organization_id,
1079-
providers=["github", "gitlab", "github_enterprise"],
1079+
providers=[
1080+
"github",
1081+
"gitlab",
1082+
"github_enterprise",
1083+
"bitbucket_server",
1084+
],
10801085
)
10811086
has_integrations = len(org_integrations) > 0
10821087
# Cache the integrations check for 4 hours

0 commit comments

Comments
 (0)