Skip to content
Merged
12 changes: 12 additions & 0 deletions docs/docs/usage-guide/additional_configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ expand_submodule_diffs = true

When enabled, PR-Agent will fetch and attach diffs from the submodule repositories. The default is `false` to avoid extra GitLab API calls.

## Post the review as a GitLab thread

By default, PR-Agent posts the `/review` summary as a plain note. To post it as a resolvable thread (GitLab discussion) instead, enable (default: `false`):

```toml
[gitlab]
publish_review_as_thread = true
```
- With `pr_reviewer.persistent_comment=true` (the default), each run updates the existing review thread and reopens it if it was resolved, so the refreshed review gets another look.
- Enabling the flag does not convert a review that was already posted as a plain note: it keeps being updated in place, and GitLab cannot promote a note to a thread. Only MRs whose first review runs after the flag is set get a thread.
- Set `pr_reviewer.persistent_comment=false` to open a new review thread on each run instead.

## Log Level

PR-Agent allows you to control the verbosity of logging by using the `log_level` configuration parameter. This is particularly useful for troubleshooting and debugging issues with your PR workflows.
Expand Down
24 changes: 20 additions & 4 deletions pr_agent/git_providers/git_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,18 +340,26 @@ def get_lines_link_original_file(self, filepath:str, component_range: Range) ->
def publish_comment(self, pr_comment: str, is_temporary: bool = False):
pass

def should_publish_review_as_thread(self) -> bool:
return False

def unresolve_comment_thread(self, comment): # noqa: B027 - intentional no-op
pass

def publish_persistent_comment(self, pr_comment: str,
initial_header: str,
update_header: bool = True,
name='review',
final_update_message=True):
return self.publish_comment(pr_comment)
final_update_message=True,
as_thread: bool = False):
return self.publish_comment(pr_comment, **({'as_thread': True} if as_thread else {}))

def publish_persistent_comment_full(self, pr_comment: str,
initial_header: str,
update_header: bool = True,
name='review',
final_update_message=True):
final_update_message=True,
as_thread: bool = False):
try:
prev_comments = list(self.get_issue_comments())
for comment in prev_comments:
Expand All @@ -366,14 +374,22 @@ def publish_persistent_comment_full(self, pr_comment: str,
get_logger().info(f"Persistent mode - updating comment {comment_url} to latest {name} message")
# response = self.mr.notes.update(comment.id, {'body': pr_comment_updated})
self.edit_comment(comment, pr_comment_updated)
if as_thread:
try:
# Reopen the thread if it was resolved, so the developer revisits the updated review.
self.unresolve_comment_thread(comment)
except Exception as e:
# The review was already updated in place; a reopen failure must not reach the
# outer except, whose fallback publish would duplicate the review.
get_logger().warning(f"Failed to reopen review thread: {e}")
if final_update_message:
return self.publish_comment(
f"**[Persistent {name}]({comment_url})** updated to latest commit {latest_commit_url}")
return comment
except Exception as e:
get_logger().exception(f"Failed to update persistent review, error: {e}")
pass
return self.publish_comment(pr_comment)
return self.publish_comment(pr_comment, **({'as_thread': True} if as_thread else {}))

@abstractmethod
def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None):
Expand Down
44 changes: 41 additions & 3 deletions pr_agent/git_providers/gitlab_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,18 +510,39 @@ def get_latest_commit_url(self):
def get_comment_url(self, comment):
return f"{self.mr.web_url}#note_{comment.id}"

def should_publish_review_as_thread(self) -> bool:
return bool(get_settings().get("GITLAB.PUBLISH_REVIEW_AS_THREAD", False))

def publish_persistent_comment(self, pr_comment: str,
initial_header: str,
update_header: bool = True,
name='review',
final_update_message=True):
self.publish_persistent_comment_full(pr_comment, initial_header, update_header, name, final_update_message)
final_update_message=True,
as_thread: bool = False):
self.publish_persistent_comment_full(pr_comment, initial_header, update_header, name, final_update_message,
as_thread=as_thread)

def publish_comment(self, mr_comment: str, is_temporary: bool = False):
def publish_comment(self, mr_comment: str, is_temporary: bool = False, as_thread: bool = False):
if is_temporary and not get_settings().config.publish_output_progress:
get_logger().debug(f"Skipping publish_comment for temporary comment: {mr_comment}")
return None
mr_comment = self.limit_output_characters(mr_comment, self.max_comment_chars)
# When as_thread is set (only the review's final comment requests this), post it as a resolvable
# thread (discussion) instead of a plain note. Temporary progress comments are never threaded.
if as_thread and not is_temporary:
try:
discussion = self.mr.discussions.create({'body': mr_comment})
except Exception as e:
get_logger().warning(f"Failed to publish comment as a thread, falling back to a note: {e}")
else:
# Return the underlying note so callers keep note-level semantics (edit/remove/url by id).
# The thread already exists here, so a failure must not fall back to a note
# (it would duplicate the review); return None instead.
try:
return self.mr.notes.get(discussion.attributes['notes'][0]['id'])
except Exception as e:
get_logger().warning(f"Published review thread but failed to fetch its note: {e}")
return None
comment = self.mr.notes.create({'body': mr_comment})
if is_temporary:
self.temp_comments.append(comment)
Expand All @@ -531,6 +552,23 @@ def edit_comment(self, comment, body: str):
body = self.limit_output_characters(body, self.max_comment_chars)
self.mr.notes.update(comment.id,{'body': body} )

def unresolve_comment_thread(self, comment):
try:
# Notes carry their own resolution state; skip the full discussions scan (the API offers no
# note -> discussion lookup) unless the note reports it is actually resolved.
if getattr(comment, 'resolvable', None) is False or getattr(comment, 'resolved', None) is False:
return
for discussion in self.mr.discussions.list(get_all=True):
notes = discussion.attributes.get('notes', [])
if not any(note.get('id') == comment.id for note in notes):
continue
if any(note.get('resolvable') and note.get('resolved') for note in notes):
discussion.resolved = False
discussion.save()
return
except Exception as e:
get_logger().warning(f"Failed to reopen resolved review thread: {e}")

def edit_comment_from_comment_id(self, comment_id: int, body: str):
body = self.limit_output_characters(body, self.max_comment_chars)
comment = self.mr.notes.get(comment_id)
Expand Down
2 changes: 2 additions & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ push_commands = [
[gitlab]
url = "https://gitlab.com"
expand_submodule_diffs = false
# Post the /review summary as a resolvable thread (discussion) instead of a plain note.
publish_review_as_thread = false
pr_commands = [
"/describe --pr_description.final_update_message=false",
"/review",
Expand Down
8 changes: 6 additions & 2 deletions pr_agent/tools/pr_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,18 @@ async def run(self) -> None:
return

# publish the review
# Providers that support it (GitLab) can post the review's final comment as a resolvable thread.
# This intent applies to the review only - never to status comments or the output of other tools.
review_thread_kwargs = {"as_thread": True} if self.git_provider.should_publish_review_as_thread() else {}
if get_settings().pr_reviewer.persistent_comment and not self.incremental.is_incremental:
final_update_message = get_settings().pr_reviewer.final_update_message
self.git_provider.publish_persistent_comment(pr_review,
initial_header=f"{PRReviewHeader.REGULAR.value} 🔍",
update_header=True,
final_update_message=final_update_message, )
final_update_message=final_update_message,
**review_thread_kwargs)
else:
self.git_provider.publish_comment(pr_review)
self.git_provider.publish_comment(pr_review, **review_thread_kwargs)

self.git_provider.remove_initial_comment()
except Exception as e:
Expand Down
Loading
Loading