Skip to content

Commit 46f6de5

Browse files
committed
Apply Bitbucket SCM review feedback
- Fix paginated comment fetch: previous code passed base_url='' for the absolute 'next' URL, which CliClient (falsy check) silently mapped to Socket's API base. Split the URL into origin + path so the request hits Bitbucket. - Drop content.get('markup') body fallback (markup is the format name, not body text); fall back to content.get('html') instead. - Remove dead hasattr() branch in has_thumbsup_reaction. - Move PROCESSED_MARKER to top of class with a comment explaining why there's no Bearer/Basic auth-fallback retry like GitLab has. - Document that --scm bitbucket exits 2 without BITBUCKET_TOKEN or BITBUCKET_USERNAME+BITBUCKET_APP_PASSWORD set. - Expand tests: pagination (origin split, no-next, error response, no-PR), has_thumbsup_reaction (marker present/absent, no-PR, exception swallowing), _mark_comment_processed (append, idempotence, swallow update error), check_for_socket_comments classification, and _split_absolute_url round-trip. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent 37de6a0 commit 46f6de5

3 files changed

Lines changed: 232 additions & 9 deletions

File tree

socketsecurity/core/scm/bitbucket.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import os
44
import sys
55
from dataclasses import dataclass
6-
from typing import Optional
6+
from typing import Optional, Tuple
7+
from urllib.parse import urlparse
78

89
from socketsecurity import USER_AGENT
910
from socketsecurity.core import log
@@ -107,10 +108,33 @@ def _get_auth_headers(
107108

108109

109110
class Bitbucket:
111+
PROCESSED_MARKER = "<!-- socket-ignore-processed -->"
112+
113+
# No Bearer/Basic fallback retry (cf. Gitlab._request_with_fallback) because
114+
# Bitbucket's auth scheme is unambiguous: BITBUCKET_TOKEN selects Bearer,
115+
# BITBUCKET_USERNAME+BITBUCKET_APP_PASSWORD selects Basic. If both routes
116+
# fail, the credential itself is wrong, not the scheme.
117+
110118
def __init__(self, client: CliClient, config: Optional[BitbucketConfig] = None):
111119
self.config = config or BitbucketConfig.from_env()
112120
self.client = client
113121

122+
@staticmethod
123+
def _split_absolute_url(url: str) -> Tuple[str, str]:
124+
"""Split an absolute URL into (origin, path+query) for CliClient.request.
125+
126+
CliClient builds URLs as f"{base_url}/{path}", so an empty base_url
127+
would fall back to Socket's API URL. To request a Bitbucket-absolute
128+
URL (like the 'next' link in paginated responses), we hand the origin
129+
in as base_url and the path/query as path.
130+
"""
131+
parsed = urlparse(url)
132+
origin = f"{parsed.scheme}://{parsed.netloc}"
133+
path = parsed.path.lstrip("/")
134+
if parsed.query:
135+
path = f"{path}?{parsed.query}"
136+
return origin, path
137+
114138
def check_event_type(self) -> str:
115139
"""Bitbucket Pipelines does not expose a 'comment' trigger.
116140
@@ -165,11 +189,13 @@ def get_comments_for_pr(self) -> dict:
165189

166190
while True:
167191
if next_url:
168-
# Bitbucket returns absolute 'next' URLs; pass via base_url=''.
192+
# Bitbucket returns absolute 'next' URLs; split origin off so
193+
# CliClient doesn't prepend the Socket API base.
194+
origin, abs_path = self._split_absolute_url(next_url)
169195
response = self.client.request(
170-
path=next_url,
196+
path=abs_path,
171197
headers=self.config.headers,
172-
base_url="",
198+
base_url=origin,
173199
)
174200
else:
175201
response = self.client.request(
@@ -207,7 +233,9 @@ def _normalize_comment(raw: dict) -> Optional[dict]:
207233
if raw.get("deleted"):
208234
return None
209235
content = raw.get("content") or {}
210-
body = content.get("raw") or content.get("markup") or ""
236+
# Bitbucket Cloud's `markup` field is the markup type ("markdown"),
237+
# not body text; `html` is the rendered fallback for HTML-only edges.
238+
body = content.get("raw") or content.get("html") or ""
211239
user = raw.get("user") or {}
212240
normalized_user = {
213241
"login": user.get("nickname") or user.get("display_name", ""),
@@ -267,8 +295,6 @@ def handle_ignore_reactions(self, comments: dict) -> None:
267295
if "SocketSecurity ignore" in comment.body and not self.has_thumbsup_reaction(comment.id):
268296
self._mark_comment_processed(comment)
269297

270-
PROCESSED_MARKER = "<!-- socket-ignore-processed -->"
271-
272298
def has_thumbsup_reaction(self, comment_id) -> bool:
273299
"""Bitbucket has no reactions; detect our hidden processed marker."""
274300
if not self.config.pr_id:
@@ -279,8 +305,8 @@ def has_thumbsup_reaction(self, comment_id) -> bool:
279305
headers=self.config.headers,
280306
base_url=self.config.api_url,
281307
)
282-
data = response.json() if hasattr(response, "json") else {}
283-
body = ((data or {}).get("content") or {}).get("raw", "")
308+
data = response.json() or {}
309+
body = (data.get("content") or {}).get("raw", "")
284310
return self.PROCESSED_MARKER in body
285311
except Exception as error:
286312
log.debug(f"Could not fetch Bitbucket comment {comment_id} for marker check: {error}")

tests/unit/test_bitbucket.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@
99
from socketsecurity.core.scm.bitbucket import Bitbucket, BitbucketConfig
1010

1111

12+
def _make_config(pr_id="42", api_url="https://api.bitbucket.org/2.0"):
13+
return BitbucketConfig(
14+
api_url=api_url,
15+
workspace="acme",
16+
repo_slug="widgets",
17+
repository="widgets",
18+
pr_id=pr_id,
19+
source_branch="feature",
20+
destination_branch="main",
21+
default_branch="main",
22+
commit_sha="abc",
23+
is_default_branch=False,
24+
token="t",
25+
username=None,
26+
headers={"Authorization": "Bearer t", "Content-Type": "application/json"},
27+
)
28+
29+
30+
def _json_response(payload):
31+
resp = MagicMock()
32+
resp.json.return_value = payload
33+
return resp
34+
35+
1236
class TestBitbucketConfigFromEnv:
1337
@patch.dict(os.environ, {
1438
"BITBUCKET_TOKEN": "bbtoken-xyz",
@@ -193,5 +217,177 @@ def test_add_socket_comments_no_pr_is_noop(self):
193217
client.request.assert_not_called()
194218

195219

220+
class TestSplitAbsoluteUrl:
221+
def test_splits_origin_and_path(self):
222+
origin, path = Bitbucket._split_absolute_url(
223+
"https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42/comments?page=2"
224+
)
225+
assert origin == "https://api.bitbucket.org"
226+
assert path == "2.0/repositories/acme/widgets/pullrequests/42/comments?page=2"
227+
228+
def test_no_query_string(self):
229+
origin, path = Bitbucket._split_absolute_url(
230+
"https://bitbucket.example.com/rest/api/1.0/foo"
231+
)
232+
assert origin == "https://bitbucket.example.com"
233+
assert path == "rest/api/1.0/foo"
234+
235+
def test_reconstructed_url_matches_clicliclient_join(self):
236+
"""CliClient does f'{base_url}/{path}' — verify our split round-trips."""
237+
original = "https://api.bitbucket.org/2.0/foo/bar?x=1&y=2"
238+
origin, path = Bitbucket._split_absolute_url(original)
239+
assert f"{origin}/{path}" == original
240+
241+
242+
class TestBitbucketPagination:
243+
def test_follows_next_url_via_origin_split(self):
244+
scm = Bitbucket(client=MagicMock(), config=_make_config())
245+
# Use Socket-style bodies so check_for_socket_comments keeps them and
246+
# we can observe that BOTH pages were fetched.
247+
page1 = {
248+
"values": [
249+
{
250+
"id": 1,
251+
"content": {"raw": "socket-security-comment-actions: page1"},
252+
"user": {"nickname": "alice", "uuid": "{u-1}"},
253+
}
254+
],
255+
"next": (
256+
"https://api.bitbucket.org/2.0/repositories/acme/widgets"
257+
"/pullrequests/42/comments?page=2"
258+
),
259+
}
260+
page2 = {
261+
"values": [
262+
{
263+
"id": 2,
264+
"content": {"raw": "socket-overview-comment-actions: page2"},
265+
"user": {"nickname": "bob", "uuid": "{u-2}"},
266+
}
267+
],
268+
}
269+
scm.client.request.side_effect = [_json_response(page1), _json_response(page2)]
270+
271+
result = scm.get_comments_for_pr()
272+
273+
# Both pages were scanned: the security comment came from page 1 and
274+
# the overview comment from page 2.
275+
assert "security" in result and result["security"].body.endswith("page1")
276+
assert "overview" in result and result["overview"].body.endswith("page2")
277+
278+
# First call: relative path against Bitbucket API base.
279+
first_call = scm.client.request.call_args_list[0]
280+
assert first_call.kwargs["base_url"] == "https://api.bitbucket.org/2.0"
281+
assert "pagelen=100" in first_call.kwargs["path"]
282+
283+
# Second call: origin pulled out of the absolute next URL — must NOT
284+
# be empty (which would fall back to Socket's API URL in CliClient).
285+
second_call = scm.client.request.call_args_list[1]
286+
assert second_call.kwargs["base_url"] == "https://api.bitbucket.org"
287+
assert second_call.kwargs["base_url"] # non-empty -> avoids fallback
288+
assert second_call.kwargs["path"].startswith(
289+
"2.0/repositories/acme/widgets/pullrequests/42/comments"
290+
)
291+
assert "page=2" in second_call.kwargs["path"]
292+
293+
def test_stops_when_no_next(self):
294+
scm = Bitbucket(client=MagicMock(), config=_make_config())
295+
scm.client.request.return_value = _json_response({"values": []})
296+
scm.get_comments_for_pr()
297+
assert scm.client.request.call_count == 1
298+
299+
def test_no_pr_skips_fetch(self):
300+
scm = Bitbucket(client=MagicMock(), config=_make_config(pr_id=None))
301+
result = scm.get_comments_for_pr()
302+
assert result == {}
303+
scm.client.request.assert_not_called()
304+
305+
def test_error_response_breaks_loop(self):
306+
scm = Bitbucket(client=MagicMock(), config=_make_config())
307+
scm.client.request.return_value = _json_response(
308+
{"type": "error", "error": {"message": "boom"}}
309+
)
310+
result = scm.get_comments_for_pr()
311+
assert result == {}
312+
assert scm.client.request.call_count == 1
313+
314+
315+
class TestHasThumbsupReaction:
316+
def test_detects_processed_marker(self):
317+
scm = Bitbucket(client=MagicMock(), config=_make_config())
318+
scm.client.request.return_value = _json_response(
319+
{"content": {"raw": f"some body\n\n{Bitbucket.PROCESSED_MARKER}"}}
320+
)
321+
assert scm.has_thumbsup_reaction(123) is True
322+
323+
def test_returns_false_when_marker_absent(self):
324+
scm = Bitbucket(client=MagicMock(), config=_make_config())
325+
scm.client.request.return_value = _json_response(
326+
{"content": {"raw": "plain comment with no marker"}}
327+
)
328+
assert scm.has_thumbsup_reaction(123) is False
329+
330+
def test_returns_false_when_no_pr(self):
331+
scm = Bitbucket(client=MagicMock(), config=_make_config(pr_id=None))
332+
assert scm.has_thumbsup_reaction(123) is False
333+
scm.client.request.assert_not_called()
334+
335+
def test_returns_false_on_request_exception(self):
336+
scm = Bitbucket(client=MagicMock(), config=_make_config())
337+
scm.client.request.side_effect = RuntimeError("network down")
338+
assert scm.has_thumbsup_reaction(123) is False
339+
340+
341+
class TestMarkCommentProcessed:
342+
def test_appends_marker_and_updates(self):
343+
scm = Bitbucket(client=MagicMock(), config=_make_config())
344+
comment = MagicMock(id=99, body="original body")
345+
scm._mark_comment_processed(comment)
346+
call = scm.client.request.call_args
347+
assert call.kwargs["method"] == "PUT"
348+
payload = json.loads(call.kwargs["payload"])
349+
assert payload["content"]["raw"].startswith("original body")
350+
assert Bitbucket.PROCESSED_MARKER in payload["content"]["raw"]
351+
352+
def test_is_idempotent_when_marker_already_present(self):
353+
scm = Bitbucket(client=MagicMock(), config=_make_config())
354+
comment = MagicMock(
355+
id=99, body=f"already processed\n\n{Bitbucket.PROCESSED_MARKER}"
356+
)
357+
scm._mark_comment_processed(comment)
358+
scm.client.request.assert_not_called()
359+
360+
def test_swallows_update_errors(self):
361+
scm = Bitbucket(client=MagicMock(), config=_make_config())
362+
scm.client.request.side_effect = RuntimeError("boom")
363+
comment = MagicMock(id=99, body="x")
364+
# Should not raise.
365+
scm._mark_comment_processed(comment)
366+
367+
368+
class TestSocketCommentClassification:
369+
def test_get_comments_runs_check_for_socket_comments(self):
370+
"""Normalized comments must flow through Comments.check_for_socket_comments
371+
so the overview/security/ignore keys are populated for downstream code."""
372+
scm = Bitbucket(client=MagicMock(), config=_make_config())
373+
# A Socket-style "ignore" comment body.
374+
page = {
375+
"values": [
376+
{
377+
"id": 1,
378+
"content": {"raw": "@SocketSecurity ignore npm/foo@1.0.0"},
379+
"user": {"nickname": "alice", "uuid": "{u-1}"},
380+
}
381+
]
382+
}
383+
scm.client.request.return_value = _json_response(page)
384+
result = scm.get_comments_for_pr()
385+
# Comments.check_for_socket_comments populates the "ignore" bucket.
386+
assert "ignore" in result
387+
assert any(
388+
"SocketSecurity ignore" in c.body for c in result["ignore"]
389+
)
390+
391+
196392
if __name__ == "__main__":
197393
pytest.main([__file__])

workflows/bitbucket-pipelines.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ definitions:
2424
# "Pull requests: Write" scope
2525
# (or set BITBUCKET_USERNAME and
2626
# BITBUCKET_APP_PASSWORD for an app password)
27+
# Without either credential set, --scm bitbucket exits 2.
2728

2829
pipelines:
2930
# Run on all branches

0 commit comments

Comments
 (0)