From 37de6a0bae10e70bb17a00bca915cbce32006632 Mon Sep 17 00:00:00 2001 From: Orlando Barrera II <1621370+obarrera@users.noreply.github.com> Date: Thu, 28 May 2026 11:33:41 -0500 Subject: [PATCH 1/4] Add Bitbucket PR comment support (CE-89) Adds a Bitbucket SCM implementation mirroring the GitHub/GitLab modules so socketcli can post and update Dependency Overview and Security Issue comments on Bitbucket Cloud pull requests. Includes config from BITBUCKET_* Pipelines env vars (Bearer token or username/app-password auth), comment shape normalization onto the existing Comment dataclass, and unit tests covering env parsing, event detection, normalization, and POST/PUT payload shape. Refs CE-89 Co-Authored-By: Claude Opus 4.7 (1M context) --- socketsecurity/core/scm/bitbucket.py | 302 +++++++++++++++++++++++++++ socketsecurity/socketcli.py | 5 + tests/unit/test_bitbucket.py | 197 +++++++++++++++++ workflows/bitbucket-pipelines.yml | 31 +-- 4 files changed, 520 insertions(+), 15 deletions(-) create mode 100644 socketsecurity/core/scm/bitbucket.py create mode 100644 tests/unit/test_bitbucket.py diff --git a/socketsecurity/core/scm/bitbucket.py b/socketsecurity/core/scm/bitbucket.py new file mode 100644 index 0000000..1b5374c --- /dev/null +++ b/socketsecurity/core/scm/bitbucket.py @@ -0,0 +1,302 @@ +import base64 +import json +import os +import sys +from dataclasses import dataclass +from typing import Optional + +from socketsecurity import USER_AGENT +from socketsecurity.core import log +from socketsecurity.core.classes import Comment +from socketsecurity.core.scm_comments import Comments +from socketsecurity.socketcli import CliClient + + +@dataclass +class BitbucketConfig: + """Configuration from Bitbucket Pipelines environment variables.""" + api_url: str + workspace: str + repo_slug: str + repository: str + pr_id: Optional[str] + source_branch: Optional[str] + destination_branch: Optional[str] + default_branch: Optional[str] + commit_sha: str + is_default_branch: bool + token: str + username: Optional[str] + headers: dict + + @classmethod + def from_env(cls, pr_number: Optional[str] = None) -> "BitbucketConfig": + """Create config from Bitbucket Pipelines env vars. + + Supports two auth styles: + - Bearer: BITBUCKET_TOKEN (workspace/repo/project access tokens, OAuth) + - Basic: BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD + """ + token = os.getenv("BITBUCKET_TOKEN", "") + username = os.getenv("BITBUCKET_USERNAME") + app_password = os.getenv("BITBUCKET_APP_PASSWORD") + + if not token and not (username and app_password): + log.error( + "Unable to get Bitbucket credentials. Set BITBUCKET_TOKEN, " + "or BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD." + ) + sys.exit(2) + + api_url = os.getenv("BITBUCKET_API_URL", "https://api.bitbucket.org/2.0").rstrip("/") + + repo_full_name = os.getenv("BITBUCKET_REPO_FULL_NAME", "") + workspace = os.getenv("BITBUCKET_WORKSPACE", "") + repo_slug = os.getenv("BITBUCKET_REPO_SLUG", "") + if repo_full_name and "/" in repo_full_name: + full_workspace, full_slug = repo_full_name.split("/", 1) + workspace = workspace or full_workspace + repo_slug = repo_slug or full_slug + + pr_id = pr_number or os.getenv("BITBUCKET_PR_ID") + if pr_id == "0": + pr_id = None + + source_branch = os.getenv("BITBUCKET_BRANCH") + destination_branch = os.getenv("BITBUCKET_PR_DESTINATION_BRANCH") + default_branch = os.getenv("BITBUCKET_DEFAULT_BRANCH") + commit_sha = os.getenv("BITBUCKET_COMMIT", "") + + is_default_branch = bool( + source_branch and default_branch and source_branch == default_branch + ) + + headers = cls._get_auth_headers(token, username, app_password) + + return cls( + api_url=api_url, + workspace=workspace, + repo_slug=repo_slug, + repository=repo_slug, + pr_id=pr_id, + source_branch=source_branch, + destination_branch=destination_branch, + default_branch=default_branch, + commit_sha=commit_sha, + is_default_branch=is_default_branch, + token=token, + username=username, + headers=headers, + ) + + @staticmethod + def _get_auth_headers( + token: str, + username: Optional[str], + app_password: Optional[str], + ) -> dict: + base_headers = { + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + } + if token: + return {**base_headers, "Authorization": f"Bearer {token}"} + encoded = base64.b64encode(f"{username}:{app_password}".encode()).decode("ascii") + return {**base_headers, "Authorization": f"Basic {encoded}"} + + +class Bitbucket: + def __init__(self, client: CliClient, config: Optional[BitbucketConfig] = None): + self.config = config or BitbucketConfig.from_env() + self.client = client + + def check_event_type(self) -> str: + """Bitbucket Pipelines does not expose a 'comment' trigger. + + If a PR id is set we treat the run as a diff; otherwise main branch. + """ + if self.config.pr_id: + return "diff" + return "main" + + def _pr_comments_path(self, comment_id: Optional[str] = None) -> str: + base = ( + f"repositories/{self.config.workspace}/{self.config.repo_slug}" + f"/pullrequests/{self.config.pr_id}/comments" + ) + if comment_id: + return f"{base}/{comment_id}" + return base + + def post_comment(self, body: str) -> None: + path = self._pr_comments_path() + payload = json.dumps({"content": {"raw": body}}) + self.client.request( + path=path, + payload=payload, + method="POST", + headers=self.config.headers, + base_url=self.config.api_url, + ) + + def update_comment(self, body: str, comment_id: str) -> None: + path = self._pr_comments_path(comment_id) + payload = json.dumps({"content": {"raw": body}}) + self.client.request( + path=path, + payload=payload, + method="PUT", + headers=self.config.headers, + base_url=self.config.api_url, + ) + + def get_comments_for_pr(self) -> dict: + log.debug( + f"Getting Bitbucket comments for Repo {self.config.repo_slug} " + f"for PR {self.config.pr_id}" + ) + comments: dict = {} + if not self.config.pr_id: + return comments + + next_url: Optional[str] = None + first_path = f"{self._pr_comments_path()}?pagelen=100" + + while True: + if next_url: + # Bitbucket returns absolute 'next' URLs; pass via base_url=''. + response = self.client.request( + path=next_url, + headers=self.config.headers, + base_url="", + ) + else: + response = self.client.request( + path=first_path, + headers=self.config.headers, + base_url=self.config.api_url, + ) + data = Comments.process_response(response) + if not isinstance(data, dict): + log.error(f"Unexpected Bitbucket comments response: {data}") + break + if data.get("type") == "error" or "error" in data: + log.error(data) + break + + for raw in data.get("values", []): + normalized = self._normalize_comment(raw) + if normalized is None: + continue + comment = Comment(**normalized) + comment.body_list = comment.body.split("\n") + comments[comment.id] = comment + + next_url = data.get("next") + if not next_url: + break + + return Comments.check_for_socket_comments(comments) + + @staticmethod + def _normalize_comment(raw: dict) -> Optional[dict]: + """Map a Bitbucket Cloud comment payload to the Comment shape.""" + if not isinstance(raw, dict): + return None + if raw.get("deleted"): + return None + content = raw.get("content") or {} + body = content.get("raw") or content.get("markup") or "" + user = raw.get("user") or {} + normalized_user = { + "login": user.get("nickname") or user.get("display_name", ""), + "username": user.get("nickname") or user.get("display_name", ""), + "id": user.get("uuid", ""), + "display_name": user.get("display_name", ""), + } + return { + "id": raw.get("id"), + "body": body, + "user": normalized_user, + "created_at": raw.get("created_on", ""), + "updated_at": raw.get("updated_on", ""), + "html_url": (raw.get("links") or {}).get("html", {}).get("href", ""), + "url": (raw.get("links") or {}).get("self", {}).get("href", ""), + "reactions": {}, + } + + def add_socket_comments( + self, + security_comment: str, + overview_comment: str, + comments: dict, + new_security_comment: bool = True, + new_overview_comment: bool = True, + ) -> None: + if not self.config.pr_id: + log.debug("No Bitbucket PR id, skipping comment posting") + return + + if new_overview_comment: + log.debug("New Dependency Overview comment") + if overview := comments.get("overview"): + log.debug("Previous version of Dependency Overview, updating") + self.update_comment(overview_comment, str(overview.id)) + else: + log.debug("No previous version of Dependency Overview, posting") + self.post_comment(overview_comment) + + if new_security_comment: + log.debug("New Security Issue Comment") + if security := comments.get("security"): + log.debug("Previous version of Security Issue comment, updating") + self.update_comment(security_comment, str(security.id)) + else: + log.debug("No previous version of Security Issue comment, posting") + self.post_comment(security_comment) + + def handle_ignore_reactions(self, comments: dict) -> None: + """Bitbucket Cloud comments have no native reactions API equivalent. + + We mark ignore comments as processed by editing them to append a + hidden Socket marker. Subsequent runs check for this marker via + has_thumbsup_reaction(). + """ + for comment in comments.get("ignore", []): + if "SocketSecurity ignore" in comment.body and not self.has_thumbsup_reaction(comment.id): + self._mark_comment_processed(comment) + + PROCESSED_MARKER = "" + + def has_thumbsup_reaction(self, comment_id) -> bool: + """Bitbucket has no reactions; detect our hidden processed marker.""" + if not self.config.pr_id: + return False + try: + response = self.client.request( + path=self._pr_comments_path(str(comment_id)), + headers=self.config.headers, + base_url=self.config.api_url, + ) + data = response.json() if hasattr(response, "json") else {} + body = ((data or {}).get("content") or {}).get("raw", "") + return self.PROCESSED_MARKER in body + except Exception as error: + log.debug(f"Could not fetch Bitbucket comment {comment_id} for marker check: {error}") + return False + + def _mark_comment_processed(self, comment) -> None: + if self.PROCESSED_MARKER in comment.body: + return + new_body = f"{comment.body}\n\n{self.PROCESSED_MARKER}" + try: + self.update_comment(new_body, str(comment.id)) + except Exception as error: + log.debug(f"Failed to mark Bitbucket ignore comment {comment.id} as processed: {error}") + + def remove_comment_alerts(self, comments: dict) -> None: + if security_alert := comments.get("security"): + new_body = Comments.process_security_comment(security_alert, comments) + self.handle_ignore_reactions(comments) + self.update_comment(new_body, str(security_alert.id)) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 1f2b166..484ee8b 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -349,6 +349,11 @@ def main_code(): from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig gitlab_config = GitlabConfig.from_env() scm = Gitlab(client=client, config=gitlab_config) + elif config.scm == 'bitbucket': + from socketsecurity.core.scm.bitbucket import Bitbucket, BitbucketConfig + pr_number = config.pr_number if config.pr_number != "0" else None + bitbucket_config = BitbucketConfig.from_env(pr_number=pr_number) + scm = Bitbucket(client=client, config=bitbucket_config) # Don't override config.default_branch if it was explicitly set via --default-branch flag # Only use SCM detection if --default-branch wasn't provided if scm is not None and not config.default_branch: diff --git a/tests/unit/test_bitbucket.py b/tests/unit/test_bitbucket.py new file mode 100644 index 0000000..ced09b6 --- /dev/null +++ b/tests/unit/test_bitbucket.py @@ -0,0 +1,197 @@ +"""Tests for Bitbucket SCM support.""" +import base64 +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from socketsecurity.core.scm.bitbucket import Bitbucket, BitbucketConfig + + +class TestBitbucketConfigFromEnv: + @patch.dict(os.environ, { + "BITBUCKET_TOKEN": "bbtoken-xyz", + "BITBUCKET_REPO_FULL_NAME": "acme/widgets", + "BITBUCKET_PR_ID": "42", + "BITBUCKET_BRANCH": "feature/x", + "BITBUCKET_PR_DESTINATION_BRANCH": "main", + "BITBUCKET_COMMIT": "deadbeef", + }, clear=True) + def test_from_env_with_token_uses_bearer(self): + config = BitbucketConfig.from_env() + assert config.workspace == "acme" + assert config.repo_slug == "widgets" + assert config.pr_id == "42" + assert config.source_branch == "feature/x" + assert config.destination_branch == "main" + assert config.commit_sha == "deadbeef" + assert config.headers["Authorization"] == "Bearer bbtoken-xyz" + assert config.headers["Content-Type"] == "application/json" + assert config.api_url == "https://api.bitbucket.org/2.0" + + @patch.dict(os.environ, { + "BITBUCKET_USERNAME": "alice", + "BITBUCKET_APP_PASSWORD": "secret", + "BITBUCKET_WORKSPACE": "acme", + "BITBUCKET_REPO_SLUG": "widgets", + }, clear=True) + def test_from_env_falls_back_to_basic_auth(self): + config = BitbucketConfig.from_env() + expected = base64.b64encode(b"alice:secret").decode("ascii") + assert config.headers["Authorization"] == f"Basic {expected}" + + @patch.dict(os.environ, {}, clear=True) + def test_from_env_missing_credentials_exits(self): + with pytest.raises(SystemExit): + BitbucketConfig.from_env() + + @patch.dict(os.environ, { + "BITBUCKET_TOKEN": "t", + "BITBUCKET_REPO_FULL_NAME": "acme/widgets", + "BITBUCKET_BRANCH": "main", + "BITBUCKET_DEFAULT_BRANCH": "main", + }, clear=True) + def test_default_branch_detected(self): + config = BitbucketConfig.from_env() + assert config.is_default_branch is True + + @patch.dict(os.environ, { + "BITBUCKET_TOKEN": "t", + "BITBUCKET_REPO_FULL_NAME": "acme/widgets", + }, clear=True) + def test_pr_number_override(self): + config = BitbucketConfig.from_env(pr_number="99") + assert config.pr_id == "99" + + +class TestBitbucketCommentNormalization: + def test_normalize_comment_extracts_raw_content(self): + raw = { + "id": 1234, + "content": {"raw": "hello world", "markup": "markdown"}, + "user": {"display_name": "Alice", "nickname": "alice", "uuid": "{u-1}"}, + "created_on": "2024-01-01T00:00:00Z", + "updated_on": "2024-01-02T00:00:00Z", + "links": {"html": {"href": "https://example.com/c/1"}}, + } + normalized = Bitbucket._normalize_comment(raw) + assert normalized["id"] == 1234 + assert normalized["body"] == "hello world" + assert normalized["user"]["login"] == "alice" + assert normalized["html_url"] == "https://example.com/c/1" + + def test_normalize_skips_deleted(self): + assert Bitbucket._normalize_comment({"id": 1, "deleted": True}) is None + + def test_normalize_handles_missing_content(self): + normalized = Bitbucket._normalize_comment({"id": 7}) + assert normalized["body"] == "" + assert normalized["id"] == 7 + + +class TestBitbucketEventDetection: + def _make(self, **overrides): + cfg = BitbucketConfig( + api_url="https://api.bitbucket.org/2.0", + workspace="acme", + repo_slug="widgets", + repository="widgets", + pr_id=overrides.get("pr_id"), + source_branch="feature", + destination_branch="main", + default_branch="main", + commit_sha="abc", + is_default_branch=False, + token="t", + username=None, + headers={}, + ) + return Bitbucket(client=MagicMock(), config=cfg) + + def test_check_event_type_diff_when_pr(self): + scm = self._make(pr_id="5") + assert scm.check_event_type() == "diff" + + def test_check_event_type_main_when_no_pr(self): + scm = self._make(pr_id=None) + assert scm.check_event_type() == "main" + + +class TestBitbucketCommentPosting: + def _make_scm(self, pr_id="42"): + cfg = BitbucketConfig( + api_url="https://api.bitbucket.org/2.0", + workspace="acme", + repo_slug="widgets", + repository="widgets", + pr_id=pr_id, + source_branch="feature", + destination_branch="main", + default_branch="main", + commit_sha="abc", + is_default_branch=False, + token="t", + username=None, + headers={"Authorization": "Bearer t", "Content-Type": "application/json"}, + ) + client = MagicMock() + return Bitbucket(client=client, config=cfg), client + + def test_post_comment_sends_json_content_raw(self): + scm, client = self._make_scm() + scm.post_comment("hello") + call = client.request.call_args + assert call.kwargs["method"] == "POST" + assert call.kwargs["path"] == ( + "repositories/acme/widgets/pullrequests/42/comments" + ) + assert call.kwargs["base_url"] == "https://api.bitbucket.org/2.0" + assert json.loads(call.kwargs["payload"]) == {"content": {"raw": "hello"}} + assert call.kwargs["headers"]["Content-Type"] == "application/json" + + def test_update_comment_uses_put_with_id(self): + scm, client = self._make_scm() + scm.update_comment("updated", "777") + call = client.request.call_args + assert call.kwargs["method"] == "PUT" + assert call.kwargs["path"].endswith("/comments/777") + assert json.loads(call.kwargs["payload"]) == {"content": {"raw": "updated"}} + + def test_add_socket_comments_creates_when_no_previous(self): + scm, client = self._make_scm() + scm.add_socket_comments( + security_comment="security body", + overview_comment="overview body", + comments={}, + new_security_comment=True, + new_overview_comment=True, + ) + # Two POSTs (overview + security), no PUTs + methods = [c.kwargs["method"] for c in client.request.call_args_list] + assert methods.count("POST") == 2 + assert "PUT" not in methods + + def test_add_socket_comments_updates_existing(self): + scm, client = self._make_scm() + existing_overview = MagicMock(id=11) + existing_security = MagicMock(id=22) + scm.add_socket_comments( + security_comment="security body", + overview_comment="overview body", + comments={"overview": existing_overview, "security": existing_security}, + new_security_comment=True, + new_overview_comment=True, + ) + methods = [c.kwargs["method"] for c in client.request.call_args_list] + assert methods.count("PUT") == 2 + assert "POST" not in methods + + def test_add_socket_comments_no_pr_is_noop(self): + scm, client = self._make_scm(pr_id=None) + scm.add_socket_comments("s", "o", {}) + client.request.assert_not_called() + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/workflows/bitbucket-pipelines.yml b/workflows/bitbucket-pipelines.yml index d129560..c52f073 100644 --- a/workflows/bitbucket-pipelines.yml +++ b/workflows/bitbucket-pipelines.yml @@ -1,6 +1,6 @@ # Socket Security Bitbucket Pipelines # This pipeline runs Socket Security scans on every commit to any branch -# The CLI automatically detects most information from the git repository +# and posts PR comments when run against a pull request. image: socketdev/cli:latest @@ -9,22 +9,21 @@ definitions: - step: &socket-scan name: Socket Security Scan script: - # Run Socket CLI with minimal required parameters - # The CLI automatically detects: - # - Repository name from git - # - Branch name from git - # - Commit SHA from git - # - Commit message from git - # - Committer information from git - # - Default branch status from git repository - # - Changed files from git commit + # Run Socket CLI with --scm bitbucket to enable PR comments. + # The CLI automatically detects repo, branch, commit, etc. from + # BITBUCKET_* env vars exposed by Pipelines. - | socketcli \ --target-path $BITBUCKET_CLONE_DIR \ - --scm api \ + --scm bitbucket \ + --integration bitbucket \ --pr-number ${BITBUCKET_PR_ID:-0} - # Repository variables needed (set in Bitbucket repo settings) + # Repository variables needed (set in Bitbucket repo settings): # SOCKET_SECURITY_API_KEY: Your Socket Security API token + # BITBUCKET_TOKEN: A Repository or Workspace access token with + # "Pull requests: Write" scope + # (or set BITBUCKET_USERNAME and + # BITBUCKET_APP_PASSWORD for an app password) pipelines: # Run on all branches @@ -76,6 +75,8 @@ pipelines: # '**': # - step: *socket-scan -# Note: Bitbucket Pipelines doesn't have built-in SCM integration like -# GitHub Actions or GitLab CI, so we use --scm api mode which provides -# basic scanning without PR comment functionality. +# Note: --scm bitbucket enables PR comments via the Bitbucket Cloud REST +# API. Generate a token at the workspace or repository level with at least +# the "Pull requests: Write" scope and expose it as BITBUCKET_TOKEN. For +# Bitbucket Server / Data Center, set BITBUCKET_API_URL to your server's +# REST API base URL. From 46f6de50c4f6127bf4b3d9500e37ab472fa5d85c Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Thu, 28 May 2026 12:48:53 -0400 Subject: [PATCH 2/4] 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> --- socketsecurity/core/scm/bitbucket.py | 44 ++++-- tests/unit/test_bitbucket.py | 196 +++++++++++++++++++++++++++ workflows/bitbucket-pipelines.yml | 1 + 3 files changed, 232 insertions(+), 9 deletions(-) diff --git a/socketsecurity/core/scm/bitbucket.py b/socketsecurity/core/scm/bitbucket.py index 1b5374c..5587d74 100644 --- a/socketsecurity/core/scm/bitbucket.py +++ b/socketsecurity/core/scm/bitbucket.py @@ -3,7 +3,8 @@ import os import sys from dataclasses import dataclass -from typing import Optional +from typing import Optional, Tuple +from urllib.parse import urlparse from socketsecurity import USER_AGENT from socketsecurity.core import log @@ -107,10 +108,33 @@ def _get_auth_headers( class Bitbucket: + PROCESSED_MARKER = "" + + # No Bearer/Basic fallback retry (cf. Gitlab._request_with_fallback) because + # Bitbucket's auth scheme is unambiguous: BITBUCKET_TOKEN selects Bearer, + # BITBUCKET_USERNAME+BITBUCKET_APP_PASSWORD selects Basic. If both routes + # fail, the credential itself is wrong, not the scheme. + def __init__(self, client: CliClient, config: Optional[BitbucketConfig] = None): self.config = config or BitbucketConfig.from_env() self.client = client + @staticmethod + def _split_absolute_url(url: str) -> Tuple[str, str]: + """Split an absolute URL into (origin, path+query) for CliClient.request. + + CliClient builds URLs as f"{base_url}/{path}", so an empty base_url + would fall back to Socket's API URL. To request a Bitbucket-absolute + URL (like the 'next' link in paginated responses), we hand the origin + in as base_url and the path/query as path. + """ + parsed = urlparse(url) + origin = f"{parsed.scheme}://{parsed.netloc}" + path = parsed.path.lstrip("/") + if parsed.query: + path = f"{path}?{parsed.query}" + return origin, path + def check_event_type(self) -> str: """Bitbucket Pipelines does not expose a 'comment' trigger. @@ -165,11 +189,13 @@ def get_comments_for_pr(self) -> dict: while True: if next_url: - # Bitbucket returns absolute 'next' URLs; pass via base_url=''. + # Bitbucket returns absolute 'next' URLs; split origin off so + # CliClient doesn't prepend the Socket API base. + origin, abs_path = self._split_absolute_url(next_url) response = self.client.request( - path=next_url, + path=abs_path, headers=self.config.headers, - base_url="", + base_url=origin, ) else: response = self.client.request( @@ -207,7 +233,9 @@ def _normalize_comment(raw: dict) -> Optional[dict]: if raw.get("deleted"): return None content = raw.get("content") or {} - body = content.get("raw") or content.get("markup") or "" + # Bitbucket Cloud's `markup` field is the markup type ("markdown"), + # not body text; `html` is the rendered fallback for HTML-only edges. + body = content.get("raw") or content.get("html") or "" user = raw.get("user") or {} normalized_user = { "login": user.get("nickname") or user.get("display_name", ""), @@ -267,8 +295,6 @@ def handle_ignore_reactions(self, comments: dict) -> None: if "SocketSecurity ignore" in comment.body and not self.has_thumbsup_reaction(comment.id): self._mark_comment_processed(comment) - PROCESSED_MARKER = "" - def has_thumbsup_reaction(self, comment_id) -> bool: """Bitbucket has no reactions; detect our hidden processed marker.""" if not self.config.pr_id: @@ -279,8 +305,8 @@ def has_thumbsup_reaction(self, comment_id) -> bool: headers=self.config.headers, base_url=self.config.api_url, ) - data = response.json() if hasattr(response, "json") else {} - body = ((data or {}).get("content") or {}).get("raw", "") + data = response.json() or {} + body = (data.get("content") or {}).get("raw", "") return self.PROCESSED_MARKER in body except Exception as error: log.debug(f"Could not fetch Bitbucket comment {comment_id} for marker check: {error}") diff --git a/tests/unit/test_bitbucket.py b/tests/unit/test_bitbucket.py index ced09b6..cf491a4 100644 --- a/tests/unit/test_bitbucket.py +++ b/tests/unit/test_bitbucket.py @@ -9,6 +9,30 @@ from socketsecurity.core.scm.bitbucket import Bitbucket, BitbucketConfig +def _make_config(pr_id="42", api_url="https://api.bitbucket.org/2.0"): + return BitbucketConfig( + api_url=api_url, + workspace="acme", + repo_slug="widgets", + repository="widgets", + pr_id=pr_id, + source_branch="feature", + destination_branch="main", + default_branch="main", + commit_sha="abc", + is_default_branch=False, + token="t", + username=None, + headers={"Authorization": "Bearer t", "Content-Type": "application/json"}, + ) + + +def _json_response(payload): + resp = MagicMock() + resp.json.return_value = payload + return resp + + class TestBitbucketConfigFromEnv: @patch.dict(os.environ, { "BITBUCKET_TOKEN": "bbtoken-xyz", @@ -193,5 +217,177 @@ def test_add_socket_comments_no_pr_is_noop(self): client.request.assert_not_called() +class TestSplitAbsoluteUrl: + def test_splits_origin_and_path(self): + origin, path = Bitbucket._split_absolute_url( + "https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42/comments?page=2" + ) + assert origin == "https://api.bitbucket.org" + assert path == "2.0/repositories/acme/widgets/pullrequests/42/comments?page=2" + + def test_no_query_string(self): + origin, path = Bitbucket._split_absolute_url( + "https://bitbucket.example.com/rest/api/1.0/foo" + ) + assert origin == "https://bitbucket.example.com" + assert path == "rest/api/1.0/foo" + + def test_reconstructed_url_matches_clicliclient_join(self): + """CliClient does f'{base_url}/{path}' — verify our split round-trips.""" + original = "https://api.bitbucket.org/2.0/foo/bar?x=1&y=2" + origin, path = Bitbucket._split_absolute_url(original) + assert f"{origin}/{path}" == original + + +class TestBitbucketPagination: + def test_follows_next_url_via_origin_split(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + # Use Socket-style bodies so check_for_socket_comments keeps them and + # we can observe that BOTH pages were fetched. + page1 = { + "values": [ + { + "id": 1, + "content": {"raw": "socket-security-comment-actions: page1"}, + "user": {"nickname": "alice", "uuid": "{u-1}"}, + } + ], + "next": ( + "https://api.bitbucket.org/2.0/repositories/acme/widgets" + "/pullrequests/42/comments?page=2" + ), + } + page2 = { + "values": [ + { + "id": 2, + "content": {"raw": "socket-overview-comment-actions: page2"}, + "user": {"nickname": "bob", "uuid": "{u-2}"}, + } + ], + } + scm.client.request.side_effect = [_json_response(page1), _json_response(page2)] + + result = scm.get_comments_for_pr() + + # Both pages were scanned: the security comment came from page 1 and + # the overview comment from page 2. + assert "security" in result and result["security"].body.endswith("page1") + assert "overview" in result and result["overview"].body.endswith("page2") + + # First call: relative path against Bitbucket API base. + first_call = scm.client.request.call_args_list[0] + assert first_call.kwargs["base_url"] == "https://api.bitbucket.org/2.0" + assert "pagelen=100" in first_call.kwargs["path"] + + # Second call: origin pulled out of the absolute next URL — must NOT + # be empty (which would fall back to Socket's API URL in CliClient). + second_call = scm.client.request.call_args_list[1] + assert second_call.kwargs["base_url"] == "https://api.bitbucket.org" + assert second_call.kwargs["base_url"] # non-empty -> avoids fallback + assert second_call.kwargs["path"].startswith( + "2.0/repositories/acme/widgets/pullrequests/42/comments" + ) + assert "page=2" in second_call.kwargs["path"] + + def test_stops_when_no_next(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response({"values": []}) + scm.get_comments_for_pr() + assert scm.client.request.call_count == 1 + + def test_no_pr_skips_fetch(self): + scm = Bitbucket(client=MagicMock(), config=_make_config(pr_id=None)) + result = scm.get_comments_for_pr() + assert result == {} + scm.client.request.assert_not_called() + + def test_error_response_breaks_loop(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"type": "error", "error": {"message": "boom"}} + ) + result = scm.get_comments_for_pr() + assert result == {} + assert scm.client.request.call_count == 1 + + +class TestHasThumbsupReaction: + def test_detects_processed_marker(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"content": {"raw": f"some body\n\n{Bitbucket.PROCESSED_MARKER}"}} + ) + assert scm.has_thumbsup_reaction(123) is True + + def test_returns_false_when_marker_absent(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"content": {"raw": "plain comment with no marker"}} + ) + assert scm.has_thumbsup_reaction(123) is False + + def test_returns_false_when_no_pr(self): + scm = Bitbucket(client=MagicMock(), config=_make_config(pr_id=None)) + assert scm.has_thumbsup_reaction(123) is False + scm.client.request.assert_not_called() + + def test_returns_false_on_request_exception(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.side_effect = RuntimeError("network down") + assert scm.has_thumbsup_reaction(123) is False + + +class TestMarkCommentProcessed: + def test_appends_marker_and_updates(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + comment = MagicMock(id=99, body="original body") + scm._mark_comment_processed(comment) + call = scm.client.request.call_args + assert call.kwargs["method"] == "PUT" + payload = json.loads(call.kwargs["payload"]) + assert payload["content"]["raw"].startswith("original body") + assert Bitbucket.PROCESSED_MARKER in payload["content"]["raw"] + + def test_is_idempotent_when_marker_already_present(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + comment = MagicMock( + id=99, body=f"already processed\n\n{Bitbucket.PROCESSED_MARKER}" + ) + scm._mark_comment_processed(comment) + scm.client.request.assert_not_called() + + def test_swallows_update_errors(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.side_effect = RuntimeError("boom") + comment = MagicMock(id=99, body="x") + # Should not raise. + scm._mark_comment_processed(comment) + + +class TestSocketCommentClassification: + def test_get_comments_runs_check_for_socket_comments(self): + """Normalized comments must flow through Comments.check_for_socket_comments + so the overview/security/ignore keys are populated for downstream code.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + # A Socket-style "ignore" comment body. + page = { + "values": [ + { + "id": 1, + "content": {"raw": "@SocketSecurity ignore npm/foo@1.0.0"}, + "user": {"nickname": "alice", "uuid": "{u-1}"}, + } + ] + } + scm.client.request.return_value = _json_response(page) + result = scm.get_comments_for_pr() + # Comments.check_for_socket_comments populates the "ignore" bucket. + assert "ignore" in result + assert any( + "SocketSecurity ignore" in c.body for c in result["ignore"] + ) + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/workflows/bitbucket-pipelines.yml b/workflows/bitbucket-pipelines.yml index c52f073..2ae9f81 100644 --- a/workflows/bitbucket-pipelines.yml +++ b/workflows/bitbucket-pipelines.yml @@ -24,6 +24,7 @@ definitions: # "Pull requests: Write" scope # (or set BITBUCKET_USERNAME and # BITBUCKET_APP_PASSWORD for an app password) + # Without either credential set, --scm bitbucket exits 2. pipelines: # Run on all branches From 6f520e6429b312e8bb5b2196ee03159a92addb96 Mon Sep 17 00:00:00 2001 From: Orlando Barrera II <1621370+obarrera@users.noreply.github.com> Date: Thu, 28 May 2026 11:53:05 -0500 Subject: [PATCH 3/4] Bump version to 2.2.91 Required by the check_version workflow now that main is at 2.2.90. Bumps __version__ in socketsecurity/__init__.py and pyproject.toml; uv.lock regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 81b9d87..332d4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.90" +version = "2.2.91" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 90049de..7fdf737 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.90' +__version__ = '2.2.91' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/uv.lock b/uv.lock index ec4b94f..3b252f7 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,7 +1168,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.90" +version = "2.2.91" source = { editable = "." } dependencies = [ { name = "bs4" }, From 96433eee2e030e21d14f662f46b90aaf3ca5537e Mon Sep 17 00:00:00 2001 From: Orlando Barrera II <1621370+obarrera@users.noreply.github.com> Date: Thu, 28 May 2026 12:04:21 -0500 Subject: [PATCH 4/4] Apply code-review improvements + bump to 2.2.92 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/scm/bitbucket.py | 32 +++++++++++-- tests/unit/test_bitbucket.py | 68 ++++++++++++++++++++++++++++ uv.lock | 2 +- workflows/bitbucket-pipelines.yml | 15 ++++-- 6 files changed, 109 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 332d4ae..466d25f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.91" +version = "2.2.92" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 7fdf737..9f58d90 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.91' +__version__ = '2.2.92' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/scm/bitbucket.py b/socketsecurity/core/scm/bitbucket.py index 5587d74..f464c9a 100644 --- a/socketsecurity/core/scm/bitbucket.py +++ b/socketsecurity/core/scm/bitbucket.py @@ -3,7 +3,7 @@ import os import sys from dataclasses import dataclass -from typing import Optional, Tuple +from typing import Optional from urllib.parse import urlparse from socketsecurity import USER_AGENT @@ -59,6 +59,14 @@ def from_env(cls, pr_number: Optional[str] = None) -> "BitbucketConfig": workspace = workspace or full_workspace repo_slug = repo_slug or full_slug + if not workspace or not repo_slug: + log.error( + "Unable to determine Bitbucket workspace/repo. Set " + "BITBUCKET_REPO_FULL_NAME, or BITBUCKET_WORKSPACE + " + "BITBUCKET_REPO_SLUG." + ) + sys.exit(2) + pr_id = pr_number or os.getenv("BITBUCKET_PR_ID") if pr_id == "0": pr_id = None @@ -118,9 +126,13 @@ class Bitbucket: def __init__(self, client: CliClient, config: Optional[BitbucketConfig] = None): self.config = config or BitbucketConfig.from_env() self.client = client + # Populated by get_comments_for_pr; consulted by has_thumbsup_reaction + # to avoid one extra GET per ignore comment when the body is already + # in memory. + self._comment_body_cache: dict = {} @staticmethod - def _split_absolute_url(url: str) -> Tuple[str, str]: + def _split_absolute_url(url: str) -> tuple[str, str]: """Split an absolute URL into (origin, path+query) for CliClient.request. CliClient builds URLs as f"{base_url}/{path}", so an empty base_url @@ -211,13 +223,14 @@ def get_comments_for_pr(self) -> dict: log.error(data) break - for raw in data.get("values", []): + for raw in data.get("values") or []: normalized = self._normalize_comment(raw) if normalized is None: continue comment = Comment(**normalized) comment.body_list = comment.body.split("\n") comments[comment.id] = comment + self._comment_body_cache[comment.id] = comment.body next_url = data.get("next") if not next_url: @@ -296,7 +309,16 @@ def handle_ignore_reactions(self, comments: dict) -> None: self._mark_comment_processed(comment) def has_thumbsup_reaction(self, comment_id) -> bool: - """Bitbucket has no reactions; detect our hidden processed marker.""" + """Bitbucket has no reactions; detect our hidden processed marker. + + Prefers the in-memory body cache populated by get_comments_for_pr; + only falls back to a GET when called for an id we haven't loaded + (defensive — currently no call path does this). + """ + cached_body = self._comment_body_cache.get(comment_id) + if cached_body is not None: + return self.PROCESSED_MARKER in cached_body + if not self.config.pr_id: return False try: @@ -318,6 +340,8 @@ def _mark_comment_processed(self, comment) -> None: new_body = f"{comment.body}\n\n{self.PROCESSED_MARKER}" try: self.update_comment(new_body, str(comment.id)) + comment.body = new_body + self._comment_body_cache[comment.id] = new_body except Exception as error: log.debug(f"Failed to mark Bitbucket ignore comment {comment.id} as processed: {error}") diff --git a/tests/unit/test_bitbucket.py b/tests/unit/test_bitbucket.py index cf491a4..6d3fd34 100644 --- a/tests/unit/test_bitbucket.py +++ b/tests/unit/test_bitbucket.py @@ -70,6 +70,13 @@ def test_from_env_missing_credentials_exits(self): with pytest.raises(SystemExit): BitbucketConfig.from_env() + @patch.dict(os.environ, {"BITBUCKET_TOKEN": "t"}, clear=True) + def test_from_env_missing_workspace_repo_exits(self): + """Credentials present but no workspace/repo info — fail fast rather + than building 404-bound URLs deeper in the request flow.""" + with pytest.raises(SystemExit): + BitbucketConfig.from_env() + @patch.dict(os.environ, { "BITBUCKET_TOKEN": "t", "BITBUCKET_REPO_FULL_NAME": "acme/widgets", @@ -389,5 +396,66 @@ def test_get_comments_runs_check_for_socket_comments(self): ) +class TestCommentBodyCache: + def test_has_thumbsup_uses_cache_after_fetch(self): + """get_comments_for_pr should populate the body cache so subsequent + has_thumbsup_reaction calls don't issue extra GETs per ignore comment.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + page = { + "values": [ + { + "id": 1, + "content": {"raw": f"@SocketSecurity ignore npm/foo@1.0.0\n\n{Bitbucket.PROCESSED_MARKER}"}, + "user": {"nickname": "alice", "uuid": "{u-1}"}, + }, + { + "id": 2, + "content": {"raw": "@SocketSecurity ignore npm/bar@2.0.0"}, + "user": {"nickname": "bob", "uuid": "{u-2}"}, + }, + ] + } + scm.client.request.return_value = _json_response(page) + scm.get_comments_for_pr() + calls_after_fetch = scm.client.request.call_count + + # Both should resolve from cache — no additional API calls. + assert scm.has_thumbsup_reaction(1) is True + assert scm.has_thumbsup_reaction(2) is False + assert scm.client.request.call_count == calls_after_fetch + + def test_has_thumbsup_falls_back_to_api_when_not_cached(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"content": {"raw": f"body\n\n{Bitbucket.PROCESSED_MARKER}"}} + ) + # ID not seen by get_comments_for_pr — falls back to GET. + assert scm.has_thumbsup_reaction(999) is True + scm.client.request.assert_called_once() + + def test_mark_comment_processed_updates_cache(self): + """After marking, the cache reflects the new body so a subsequent + has_thumbsup_reaction in the same run sees it without re-fetching.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + comment = MagicMock(id=42, body="original") + scm._mark_comment_processed(comment) + assert scm._comment_body_cache[42].endswith(Bitbucket.PROCESSED_MARKER) + # And subsequent reaction check is a cache hit. + scm.client.request.reset_mock() + assert scm.has_thumbsup_reaction(42) is True + scm.client.request.assert_not_called() + + +class TestNullSafePaginationValues: + def test_handles_null_values_field(self): + """If Bitbucket returns {"values": null} instead of omitting the key, + the for-loop must not blow up.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response({"values": None}) + # Should not raise. + result = scm.get_comments_for_pr() + assert result == {} + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/uv.lock b/uv.lock index 3b252f7..dde4be0 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,7 +1168,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.91" +version = "2.2.92" source = { editable = "." } dependencies = [ { name = "bs4" }, diff --git a/workflows/bitbucket-pipelines.yml b/workflows/bitbucket-pipelines.yml index 2ae9f81..733dd9b 100644 --- a/workflows/bitbucket-pipelines.yml +++ b/workflows/bitbucket-pipelines.yml @@ -19,11 +19,16 @@ definitions: --integration bitbucket \ --pr-number ${BITBUCKET_PR_ID:-0} # Repository variables needed (set in Bitbucket repo settings): - # SOCKET_SECURITY_API_KEY: Your Socket Security API token - # BITBUCKET_TOKEN: A Repository or Workspace access token with - # "Pull requests: Write" scope - # (or set BITBUCKET_USERNAME and - # BITBUCKET_APP_PASSWORD for an app password) + # SOCKET_SECURITY_API_KEY: Your Socket Security API token + # BITBUCKET_TOKEN: A Repository or Workspace access token with + # "Pull requests: Write" scope + # (or set BITBUCKET_USERNAME and + # BITBUCKET_APP_PASSWORD for an app password) + # BITBUCKET_DEFAULT_BRANCH: Optional. Bitbucket Pipelines does not + # export the repo's default branch, so set + # this (e.g. "main") for accurate default- + # branch detection. You can also pass + # --default-branch on the CLI directly. # Without either credential set, --scm bitbucket exits 2. pipelines: