Skip to content

Commit 96433ee

Browse files
obarreraclaude
andcommitted
Apply code-review improvements + bump to 2.2.92
- Cache comment bodies in get_comments_for_pr so has_thumbsup_reaction resolves from memory instead of issuing one extra GET per ignore comment on every run. _mark_comment_processed refreshes the cache so in-run repeat checks also hit it. - Guard against {"values": null} responses from Bitbucket Cloud — the previous .get("values", []) default only fires on missing key, so a null value would TypeError on iteration. - Fail fast in BitbucketConfig.from_env when workspace or repo_slug can't be determined, rather than building 404-bound URLs deeper in the request flow. - Drop typing.Tuple in favor of built-in tuple (project targets 3.11+). - Document BITBUCKET_DEFAULT_BRANCH in the example pipeline since Bitbucket Pipelines doesn't export the repo default branch. - Add tests for cache hits, cache fallback, mark-processed cache refresh, null-values pagination, and the new workspace/repo validation exit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f520e6 commit 96433ee

6 files changed

Lines changed: 109 additions & 12 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.2.91"
9+
version = "2.2.92"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.2.91'
2+
__version__ = '2.2.92'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/core/scm/bitbucket.py

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

99
from socketsecurity import USER_AGENT
@@ -59,6 +59,14 @@ def from_env(cls, pr_number: Optional[str] = None) -> "BitbucketConfig":
5959
workspace = workspace or full_workspace
6060
repo_slug = repo_slug or full_slug
6161

62+
if not workspace or not repo_slug:
63+
log.error(
64+
"Unable to determine Bitbucket workspace/repo. Set "
65+
"BITBUCKET_REPO_FULL_NAME, or BITBUCKET_WORKSPACE + "
66+
"BITBUCKET_REPO_SLUG."
67+
)
68+
sys.exit(2)
69+
6270
pr_id = pr_number or os.getenv("BITBUCKET_PR_ID")
6371
if pr_id == "0":
6472
pr_id = None
@@ -118,9 +126,13 @@ class Bitbucket:
118126
def __init__(self, client: CliClient, config: Optional[BitbucketConfig] = None):
119127
self.config = config or BitbucketConfig.from_env()
120128
self.client = client
129+
# Populated by get_comments_for_pr; consulted by has_thumbsup_reaction
130+
# to avoid one extra GET per ignore comment when the body is already
131+
# in memory.
132+
self._comment_body_cache: dict = {}
121133

122134
@staticmethod
123-
def _split_absolute_url(url: str) -> Tuple[str, str]:
135+
def _split_absolute_url(url: str) -> tuple[str, str]:
124136
"""Split an absolute URL into (origin, path+query) for CliClient.request.
125137
126138
CliClient builds URLs as f"{base_url}/{path}", so an empty base_url
@@ -211,13 +223,14 @@ def get_comments_for_pr(self) -> dict:
211223
log.error(data)
212224
break
213225

214-
for raw in data.get("values", []):
226+
for raw in data.get("values") or []:
215227
normalized = self._normalize_comment(raw)
216228
if normalized is None:
217229
continue
218230
comment = Comment(**normalized)
219231
comment.body_list = comment.body.split("\n")
220232
comments[comment.id] = comment
233+
self._comment_body_cache[comment.id] = comment.body
221234

222235
next_url = data.get("next")
223236
if not next_url:
@@ -296,7 +309,16 @@ def handle_ignore_reactions(self, comments: dict) -> None:
296309
self._mark_comment_processed(comment)
297310

298311
def has_thumbsup_reaction(self, comment_id) -> bool:
299-
"""Bitbucket has no reactions; detect our hidden processed marker."""
312+
"""Bitbucket has no reactions; detect our hidden processed marker.
313+
314+
Prefers the in-memory body cache populated by get_comments_for_pr;
315+
only falls back to a GET when called for an id we haven't loaded
316+
(defensive — currently no call path does this).
317+
"""
318+
cached_body = self._comment_body_cache.get(comment_id)
319+
if cached_body is not None:
320+
return self.PROCESSED_MARKER in cached_body
321+
300322
if not self.config.pr_id:
301323
return False
302324
try:
@@ -318,6 +340,8 @@ def _mark_comment_processed(self, comment) -> None:
318340
new_body = f"{comment.body}\n\n{self.PROCESSED_MARKER}"
319341
try:
320342
self.update_comment(new_body, str(comment.id))
343+
comment.body = new_body
344+
self._comment_body_cache[comment.id] = new_body
321345
except Exception as error:
322346
log.debug(f"Failed to mark Bitbucket ignore comment {comment.id} as processed: {error}")
323347

tests/unit/test_bitbucket.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ def test_from_env_missing_credentials_exits(self):
7070
with pytest.raises(SystemExit):
7171
BitbucketConfig.from_env()
7272

73+
@patch.dict(os.environ, {"BITBUCKET_TOKEN": "t"}, clear=True)
74+
def test_from_env_missing_workspace_repo_exits(self):
75+
"""Credentials present but no workspace/repo info — fail fast rather
76+
than building 404-bound URLs deeper in the request flow."""
77+
with pytest.raises(SystemExit):
78+
BitbucketConfig.from_env()
79+
7380
@patch.dict(os.environ, {
7481
"BITBUCKET_TOKEN": "t",
7582
"BITBUCKET_REPO_FULL_NAME": "acme/widgets",
@@ -389,5 +396,66 @@ def test_get_comments_runs_check_for_socket_comments(self):
389396
)
390397

391398

399+
class TestCommentBodyCache:
400+
def test_has_thumbsup_uses_cache_after_fetch(self):
401+
"""get_comments_for_pr should populate the body cache so subsequent
402+
has_thumbsup_reaction calls don't issue extra GETs per ignore comment."""
403+
scm = Bitbucket(client=MagicMock(), config=_make_config())
404+
page = {
405+
"values": [
406+
{
407+
"id": 1,
408+
"content": {"raw": f"@SocketSecurity ignore npm/foo@1.0.0\n\n{Bitbucket.PROCESSED_MARKER}"},
409+
"user": {"nickname": "alice", "uuid": "{u-1}"},
410+
},
411+
{
412+
"id": 2,
413+
"content": {"raw": "@SocketSecurity ignore npm/bar@2.0.0"},
414+
"user": {"nickname": "bob", "uuid": "{u-2}"},
415+
},
416+
]
417+
}
418+
scm.client.request.return_value = _json_response(page)
419+
scm.get_comments_for_pr()
420+
calls_after_fetch = scm.client.request.call_count
421+
422+
# Both should resolve from cache — no additional API calls.
423+
assert scm.has_thumbsup_reaction(1) is True
424+
assert scm.has_thumbsup_reaction(2) is False
425+
assert scm.client.request.call_count == calls_after_fetch
426+
427+
def test_has_thumbsup_falls_back_to_api_when_not_cached(self):
428+
scm = Bitbucket(client=MagicMock(), config=_make_config())
429+
scm.client.request.return_value = _json_response(
430+
{"content": {"raw": f"body\n\n{Bitbucket.PROCESSED_MARKER}"}}
431+
)
432+
# ID not seen by get_comments_for_pr — falls back to GET.
433+
assert scm.has_thumbsup_reaction(999) is True
434+
scm.client.request.assert_called_once()
435+
436+
def test_mark_comment_processed_updates_cache(self):
437+
"""After marking, the cache reflects the new body so a subsequent
438+
has_thumbsup_reaction in the same run sees it without re-fetching."""
439+
scm = Bitbucket(client=MagicMock(), config=_make_config())
440+
comment = MagicMock(id=42, body="original")
441+
scm._mark_comment_processed(comment)
442+
assert scm._comment_body_cache[42].endswith(Bitbucket.PROCESSED_MARKER)
443+
# And subsequent reaction check is a cache hit.
444+
scm.client.request.reset_mock()
445+
assert scm.has_thumbsup_reaction(42) is True
446+
scm.client.request.assert_not_called()
447+
448+
449+
class TestNullSafePaginationValues:
450+
def test_handles_null_values_field(self):
451+
"""If Bitbucket returns {"values": null} instead of omitting the key,
452+
the for-loop must not blow up."""
453+
scm = Bitbucket(client=MagicMock(), config=_make_config())
454+
scm.client.request.return_value = _json_response({"values": None})
455+
# Should not raise.
456+
result = scm.get_comments_for_pr()
457+
assert result == {}
458+
459+
392460
if __name__ == "__main__":
393461
pytest.main([__file__])

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

workflows/bitbucket-pipelines.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ definitions:
1919
--integration bitbucket \
2020
--pr-number ${BITBUCKET_PR_ID:-0}
2121
# Repository variables needed (set in Bitbucket repo settings):
22-
# SOCKET_SECURITY_API_KEY: Your Socket Security API token
23-
# BITBUCKET_TOKEN: A Repository or Workspace access token with
24-
# "Pull requests: Write" scope
25-
# (or set BITBUCKET_USERNAME and
26-
# BITBUCKET_APP_PASSWORD for an app password)
22+
# SOCKET_SECURITY_API_KEY: Your Socket Security API token
23+
# BITBUCKET_TOKEN: A Repository or Workspace access token with
24+
# "Pull requests: Write" scope
25+
# (or set BITBUCKET_USERNAME and
26+
# BITBUCKET_APP_PASSWORD for an app password)
27+
# BITBUCKET_DEFAULT_BRANCH: Optional. Bitbucket Pipelines does not
28+
# export the repo's default branch, so set
29+
# this (e.g. "main") for accurate default-
30+
# branch detection. You can also pass
31+
# --default-branch on the CLI directly.
2732
# Without either credential set, --scm bitbucket exits 2.
2833

2934
pipelines:

0 commit comments

Comments
 (0)