diff --git a/atlassian/bitbucket/__init__.py b/atlassian/bitbucket/__init__.py index b3ca75542..ab7216adb 100644 --- a/atlassian/bitbucket/__init__.py +++ b/atlassian/bitbucket/__init__.py @@ -2102,6 +2102,66 @@ def add_pull_request_comment( body["parent"] = {"id": parent_id} return self.post(url, data=body) + def add_pull_request_inline_comment( + self, + project_key, + repository_slug, + pull_request_id, + text, + path, + from_hash, + to_hash, + src_path=None, + line=None, + line_type="CONTEXT", + diff_type="RANGE", + file_type=None, + parent_id=None, + ): + """ + Add an inline comment on a specific line or file within a pull request. + Supported on Bitbucket Server / Data Center REST API. + See: https://developer.atlassian.com/server/bitbucket/rest/v900/api-group-pull-requests/ + + :param project_key: The project key (e.g. "PRJ") + :param repository_slug: The repository slug (e.g. "my-repo") + :param pull_request_id: The pull request ID (int) + :param text: The comment text + :param path: The file path relative to the repo root (e.g. "src/main.py") + :param from_hash: The source commit hash (the branch HEAD before the PR) + :param to_hash: The target commit hash (the branch HEAD after the PR) + :param src_path: The source file path (defaults to path if not provided) + :param line: The line number to comment on (required for diffType=COMMIT) + :param line_type: The line type for COMMIT diffs. One of: ADDED, REMOVED, CONTEXT + :param diff_type: "RANGE" for whole-file comment, "COMMIT" for line-level comment + :param file_type: "FROM" or "TO" — which side of the diff to comment on (COMMIT only) + :param parent_id: Optional parent comment ID for threaded replies + :return: The created comment object + """ + url = self._url_pull_request_comments(project_key, repository_slug, pull_request_id) + body = {"text": text} + + anchor = { + "diffType": diff_type, + "fromHash": from_hash, + "toHash": to_hash, + "path": path, + "srcPath": src_path or path, + } + + if diff_type == "COMMIT" and line is not None: + anchor["line"] = line + anchor["lineType"] = line_type + if file_type: + anchor["fileType"] = file_type + + body["anchor"] = anchor + + if parent_id: + body["parent"] = {"id": parent_id} + + return self.post(url, data=body) + def _url_pull_request_comment(self, project_key, repository_slug, pull_request_id, comment_id): url = f"{self._url_pull_request_comments(project_key, repository_slug, pull_request_id)}/{comment_id}" return url diff --git a/tests/test_bitbucket_server.py b/tests/test_bitbucket_server.py index 8fa84ed79..7bb4e50cc 100644 --- a/tests/test_bitbucket_server.py +++ b/tests/test_bitbucket_server.py @@ -135,6 +135,92 @@ def test_configure_repo_hook_script(self, mock_put): ) +class TestPullRequestInlineComments(TestCase): + def setUp(self): + self.bitbucket = Bitbucket(f"{mockup_server()}/bitbucket/server", username="username", password="password") + self.project_key = "PRJ" + self.repository_slug = "my-repo1-slug" + self.pull_request_id = 1 + + @patch.object(Bitbucket, "post") + def test_add_inline_comment_range(self, mock_post): + self.bitbucket.add_pull_request_inline_comment( + project_key=self.project_key, + repository_slug=self.repository_slug, + pull_request_id=self.pull_request_id, + text="This whole file looks great!", + path="src/main.py", + from_hash="abc123", + to_hash="def456", + diff_type="RANGE", + ) + mock_post.assert_called_once() + call_args = mock_post.call_args + body = call_args[1]["data"] + self.assertEqual(body["text"], "This whole file looks great!") + self.assertIn("anchor", body) + self.assertEqual(body["anchor"]["diffType"], "RANGE") + self.assertEqual(body["anchor"]["path"], "src/main.py") + self.assertEqual(body["anchor"]["srcPath"], "src/main.py") + self.assertEqual(body["anchor"]["fromHash"], "abc123") + self.assertEqual(body["anchor"]["toHash"], "def456") + + @patch.object(Bitbucket, "post") + def test_add_inline_comment_commit_line(self, mock_post): + self.bitbucket.add_pull_request_inline_comment( + project_key=self.project_key, + repository_slug=self.repository_slug, + pull_request_id=self.pull_request_id, + text="This line has a bug", + path="src/utils.py", + from_hash="aaa111", + to_hash="bbb222", + line=42, + line_type="ADDED", + diff_type="COMMIT", + file_type="TO", + ) + mock_post.assert_called_once() + body = mock_post.call_args[1]["data"] + anchor = body["anchor"] + self.assertEqual(anchor["diffType"], "COMMIT") + self.assertEqual(anchor["line"], 42) + self.assertEqual(anchor["lineType"], "ADDED") + self.assertEqual(anchor["fileType"], "TO") + + @patch.object(Bitbucket, "post") + def test_add_inline_comment_with_parent(self, mock_post): + self.bitbucket.add_pull_request_inline_comment( + project_key=self.project_key, + repository_slug=self.repository_slug, + pull_request_id=self.pull_request_id, + text="I agree with this comment", + path="README.md", + from_hash="ccc333", + to_hash="ddd444", + parent_id=99, + ) + body = mock_post.call_args[1]["data"] + self.assertEqual(body["parent"]["id"], 99) + self.assertIn("anchor", body) + + @patch.object(Bitbucket, "post") + def test_add_inline_comment_custom_src_path(self, mock_post): + self.bitbucket.add_pull_request_inline_comment( + project_key=self.project_key, + repository_slug=self.repository_slug, + pull_request_id=self.pull_request_id, + text="Renamed file comment", + path="src/new_name.py", + src_path="src/old_name.py", + from_hash="eee555", + to_hash="fff666", + ) + body = mock_post.call_args[1]["data"] + self.assertEqual(body["anchor"]["path"], "src/new_name.py") + self.assertEqual(body["anchor"]["srcPath"], "src/old_name.py") + + class TestPersonalRepositories(TestCase): def setUp(self): self.bitbucket = Bitbucket("https://bitbucket.example.com", username="admin", password="password")