Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pr_agent/git_providers/bitbucket_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,8 @@ def remove_initial_comment(self):

def remove_comment(self, comment):
try:
self.pr.delete(f"comments/{comment}")
comment_id = comment["id"] if isinstance(comment, dict) else comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both shapes are genuinely live, so this needs to stay tolerant: remove_initial_comment still feeds it the bare id out of temp_comments, while the new caller passes the whole dict.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I kept it compatible with both forms. We could normalize the handle later, but I think changing the provider boundary belongs in a separate PR.

self.pr.delete(f"comments/{comment_id}")
except Exception as e:
get_logger().exception(f"Failed to remove comment, error: {e}")

Expand Down
12 changes: 8 additions & 4 deletions pr_agent/tools/pr_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def parse_incremental(self, args: List[str]):

async def run(self) -> None:
init_run_details()
progress_response = None
try:
if not self.git_provider.get_files():
get_logger().info(f"PR has no files: {self.pr_url}, skipping review")
Expand Down Expand Up @@ -175,11 +176,10 @@ async def run(self) -> None:
return None

if get_settings().config.publish_output and not get_settings().config.get('is_auto_command', False):
self.git_provider.publish_comment("Preparing review...", is_temporary=True)
progress_response = self.git_provider.publish_comment("Preparing review...", is_temporary=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked what every provider returns here against its remove_comment: GitHub and GitLab hand back a comment object that deletes, Azure a Comment carrying thread_id, Gitea a dict its remove_comment already unpacks, and Bitbucket Server, Gerrit, CodeCommit and local all return None for a temporary comment, so the new finally is a no-op there. Bitbucket Cloud was the only one that needed the fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That matches what I had in mind:

  • keep the cleanup generic
  • skip it when a provider returns None
  • handle the Bitbucket Cloud difference inside its provider.

In the future, we could standardize the comment handle across providers, but that feels separate from this PR.


await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR)
if not self.prediction:
self.git_provider.remove_initial_comment()
return None

pr_review = self._prepare_pr_review()
Expand Down Expand Up @@ -207,12 +207,16 @@ async def run(self) -> None:
**review_thread_kwargs)
else:
self.git_provider.publish_comment(pr_review, **review_thread_kwargs)

self.git_provider.remove_initial_comment()
except Exception as e:
get_logger().error(f"Failed to review PR: {e}")
if get_settings().config.get("propagate_tool_errors", False):
raise
finally:
if progress_response is not None:
try:
self.git_provider.remove_comment(progress_response)
except Exception as e:
get_logger().exception(f"Failed to remove review progress comment, error: {e}")
Comment on lines +214 to +219

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right shape, and I prefer it to the /improve version it follows, which repeats remove_comment at each exit and so has to be rechecked every time an early return is added.

One thing worth confirming as deliberate: a failed /review now leaves nothing on the PR at all. /improve posts a "Failed to generate code suggestions" notice only on the branch where there was no progress comment to remove, so it does not really settle it either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that a visible failure result could be better for manual runs.
I kept this change smaller and focused, but the additional fix does not look too large. I can add it to this PR with the relevant cross-provider behavior and tests if it fits project approach, or handle it in a follow-up PR.
What do you suggest?


def _should_publish_review_no_suggestions(self, pr_review: str) -> bool:
return get_settings().pr_reviewer.get('publish_output_no_suggestions', True) or "No major issues detected" not in pr_review
Expand Down
9 changes: 9 additions & 0 deletions tests/unittest/test_bitbucket_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ def test_get_repo_file_content_from_default_branch(self):
assert content == "repo context"
provider.get_pr_file_content.assert_called_once_with("AGENTS.md", "main")

@pytest.mark.parametrize("comment", [{"id": 123}, 123])
def test_remove_comment_accepts_returned_comment_or_stored_id(self, comment):
provider = BitbucketProvider.__new__(BitbucketProvider)
provider.pr = MagicMock()

provider.remove_comment(comment)

provider.pr.delete.assert_called_once_with("comments/123")


class TestBitbucketServerProvider:
def test_parse_pr_url(self):
Expand Down
132 changes: 132 additions & 0 deletions tests/unittest/test_pr_reviewer_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,134 @@ def test_should_publish_review_no_suggestions_respects_config():
settings.pr_reviewer.publish_output_no_suggestions = original_publish_no_suggestions


@pytest.mark.asyncio
async def test_run_removes_its_progress_comment_when_quiet_output_suppresses_review(monkeypatch):
from pr_agent.tools import pr_reviewer as pr_reviewer_module

progress_comment = MagicMock()
git_provider = MagicMock()
git_provider.get_files.return_value = ["app.py"]
git_provider.publish_comment.return_value = progress_comment
reviewer = _make_reviewer(git_provider)
reviewer.incremental = SimpleNamespace(is_incremental=False)
reviewer.vars = {}
reviewer.prediction = None
reviewer._prepare_pr_review = lambda: "No major issues detected"

async def fake_retry(prepare_fn, model_type=None):
reviewer.prediction = "prediction"

monkeypatch.setattr(pr_reviewer_module, "extract_and_cache_pr_tickets", AsyncMock())
monkeypatch.setattr(pr_reviewer_module, "retry_with_fallback_models", fake_retry)

settings = get_settings()
original = {
"publish_output": settings.config.publish_output,
"publish_output_no_suggestions": settings.pr_reviewer.publish_output_no_suggestions,
"is_auto_command": settings.config.get("is_auto_command", False),
}
try:
settings.config.publish_output = True
settings.config.is_auto_command = False
settings.pr_reviewer.publish_output_no_suggestions = False

await reviewer.run()
finally:
settings.config.publish_output = original["publish_output"]
settings.config.is_auto_command = original["is_auto_command"]
settings.pr_reviewer.publish_output_no_suggestions = original["publish_output_no_suggestions"]

git_provider.publish_comment.assert_called_once_with("Preparing review...", is_temporary=True)
git_provider.remove_comment.assert_called_once_with(progress_comment)
git_provider.remove_initial_comment.assert_not_called()
git_provider.publish_persistent_comment.assert_not_called()


@pytest.mark.asyncio
@pytest.mark.parametrize("propagate_tool_errors", [False, True])
async def test_run_removes_its_progress_comment_when_review_generation_fails(
monkeypatch, propagate_tool_errors):
from pr_agent.tools import pr_reviewer as pr_reviewer_module

progress_comment = MagicMock()
git_provider = MagicMock()
git_provider.get_files.return_value = ["app.py"]
git_provider.publish_comment.return_value = progress_comment
reviewer = _make_reviewer(git_provider)
reviewer.incremental = SimpleNamespace(is_incremental=False)
reviewer.vars = {}
reviewer.prediction = None

monkeypatch.setattr(pr_reviewer_module, "extract_and_cache_pr_tickets", AsyncMock())
monkeypatch.setattr(
pr_reviewer_module,
"retry_with_fallback_models",
AsyncMock(side_effect=RuntimeError("model unavailable")),
)

settings = get_settings()
original = {
"publish_output": settings.config.publish_output,
"is_auto_command": settings.config.get("is_auto_command", False),
"propagate_tool_errors": settings.config.get("propagate_tool_errors", False),
}
try:
settings.config.publish_output = True
settings.config.is_auto_command = False
settings.config.propagate_tool_errors = propagate_tool_errors

if propagate_tool_errors:
with pytest.raises(RuntimeError, match="model unavailable"):
await reviewer.run()
else:
await reviewer.run()
finally:
settings.config.publish_output = original["publish_output"]
settings.config.is_auto_command = original["is_auto_command"]
settings.config.propagate_tool_errors = original["propagate_tool_errors"]

git_provider.publish_comment.assert_called_once_with("Preparing review...", is_temporary=True)
git_provider.remove_comment.assert_called_once_with(progress_comment)
git_provider.remove_initial_comment.assert_not_called()


@pytest.mark.asyncio
async def test_run_does_not_remove_comments_when_progress_was_not_published(monkeypatch):
from pr_agent.tools import pr_reviewer as pr_reviewer_module

git_provider = MagicMock()
git_provider.get_files.return_value = ["app.py"]
reviewer = _make_reviewer(git_provider)
reviewer.incremental = SimpleNamespace(is_incremental=False)
reviewer.vars = {}
reviewer.prediction = None

monkeypatch.setattr(pr_reviewer_module, "extract_and_cache_pr_tickets", AsyncMock())
monkeypatch.setattr(
pr_reviewer_module,
"retry_with_fallback_models",
AsyncMock(side_effect=RuntimeError("model unavailable")),
)

settings = get_settings()
original = {
"publish_output": settings.config.publish_output,
"is_auto_command": settings.config.get("is_auto_command", False),
}
try:
settings.config.publish_output = True
settings.config.is_auto_command = True

await reviewer.run()
finally:
settings.config.publish_output = original["publish_output"]
settings.config.is_auto_command = original["is_auto_command"]

git_provider.publish_comment.assert_not_called()
git_provider.remove_comment.assert_not_called()
git_provider.remove_initial_comment.assert_not_called()


def test_can_run_incremental_review_skips_auto_mode_without_new_commit():
reviewer = _make_reviewer()
reviewer.is_auto = True
Expand Down Expand Up @@ -594,8 +722,10 @@ async def test_run_threads_only_the_final_review_comment(monkeypatch, persistent
"""
from pr_agent.tools import pr_reviewer as pr_reviewer_module

progress_comment = MagicMock()
git_provider = MagicMock()
git_provider.should_publish_review_as_thread.return_value = thread_enabled
git_provider.publish_comment.return_value = progress_comment
reviewer = _make_reviewer(git_provider)
reviewer.incremental = SimpleNamespace(is_incremental=False)
reviewer.vars = {}
Expand Down Expand Up @@ -641,6 +771,8 @@ async def fake_retry(prepare_fn, model_type=None):
assert "as_thread" not in publish.call_args.kwargs
# The temporary progress comment is published without as_thread regardless of the flag.
git_provider.publish_comment.assert_any_call("Preparing review...", is_temporary=True)
git_provider.remove_comment.assert_called_once_with(progress_comment)
git_provider.remove_initial_comment.assert_not_called()


def test_init_maps_user_question_and_answer_to_correct_prompt_vars(monkeypatch):
Expand Down
Loading