Skip to content
Closed
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
7 changes: 7 additions & 0 deletions bert_e/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class TemplateException(BertE_Exception):
template = None
# whether to re-publish if the message is already in the history
dont_repeat_if_in_history = -1
# whether the message is a status that supersedes the previous one: it
# then updates the existing status comment instead of posting a new one
updatable = False

def __init__(self, **kwargs):
self.kwargs = kwargs
Expand Down Expand Up @@ -59,6 +62,7 @@ class SilentException(BertE_Exception):
# template for informative exceptions
class InformationException(TemplateException):
dont_repeat_if_in_history = NEVER_REPEAT
updatable = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Warning — Users lose the bot's manual and greeting on every PR.

InformationException.updatable = True makes InitMessage updatable. InitMessage goes through notify_user, so the marker is appended to the greeting, and the greeting becomes the status comment. The first later updatable message (IntegrationDataCreated, ResetComplete, BranchHistoryMismatch, IncorrectFixVersion, PendingHotfixVersionReminder) edits the greeting in place. That wipes the welcome text and the list of available options and commands from the PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Warning — The status comment flip-flops and loses important messages.

PendingHotfixVersionReminder inherits updatable = True. check_pending_hotfix in jira.py skips the reminder only if a bot comment still starts with _PENDING_HOTFIX_TITLE.

  1. The reminder edits the shared status comment.
  2. Any later updatable status, such as ResetComplete or IntegrationDataCreated, overwrites that comment and removes the title.
  3. The dedup no longer finds the title, so the next run edits the reminder back in, replacing the other status.

The reminder and the other statuses keep overwriting each other in the same comment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

InitMessage inherits updatable = True from InformationException, which means the initial welcome/instructions comment will be silently replaced in place by the next updatable status message (e.g. IntegrationDataCreated). This is likely unintended — the init message serves a different purpose than status updates. Consider either overriding updatable = False on InitMessage, or setting updatable = True only on specific subclasses rather than on the base class.

— Claude Code



# template exceptions
Expand Down Expand Up @@ -131,12 +135,14 @@ class IncorrectFixVersion(TemplateException):
code = 112
template = 'incorrect_fix_version.md'
status = "failure"
updatable = True


class BranchHistoryMismatch(TemplateException):
code = 113
template = 'history_mismatch.md'
status = "failure"
updatable = True


class Conflict(TemplateException):
Expand Down Expand Up @@ -226,6 +232,7 @@ class QueueOutOfOrder(TemplateException):
class ResetComplete(TemplateException):
code = 128
template = "reset_complete.md"
updatable = True


class LossyResetWarning(TemplateException):
Expand Down
7 changes: 7 additions & 0 deletions bert_e/git_host/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ def text(self) -> str:
def id(self) -> int:
"""The comment's ID"""

def edit(self, msg: str) -> None:
"""Replace the comment's contents with `msg`.

Git hosts that cannot edit comments raise NotImplementedError.
"""
raise NotImplementedError('Comment edition is not supported.')


class AbstractPullRequest(metaclass=ABCMeta):
@abstractmethod
Expand Down
5 changes: 5 additions & 0 deletions bert_e/git_host/github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,11 @@ def id(self) -> int:
def delete(self) -> None:
self.client.delete(self.data['url'])

def edit(self, msg: str) -> None:
self.data = self.update(
self.client, {'body': msg}, url=self.data['url']
).data


class CheckRun(base.AbstractGitHostObject):
GET_URL = '/repos/{owner}/{repo}/check-runs/{id}'
Expand Down
3 changes: 3 additions & 0 deletions bert_e/git_host/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,9 @@ class CommentController(Controller, base.AbstractComment):
def delete(self):
self.controlled.delete()

def edit(self, msg):
self['content'] = {"raw": msg, "markup": "markdown", "html": msg}

@property
def author(self):
return self['user']['username'].lower()
Expand Down
89 changes: 89 additions & 0 deletions bert_e/tests/unit/test_comment_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Status comments are edited in place instead of being re-posted."""
from types import SimpleNamespace

import pytest

from bert_e import exceptions
from bert_e.workflow import pr_utils


class FakeComment:
def __init__(self, author, text, editable=True):
self.author = author
self.text = text
self.editable = editable

def edit(self, msg):
if not self.editable:
raise NotImplementedError
self.text = msg


class FakePR:
def __init__(self, editable=True):
self.comments = []
self.editable = editable

def add_comment(self, msg):
self.comments.append(FakeComment('bert-e', msg, self.editable))


@pytest.fixture
def settings():
return SimpleNamespace(no_comment=False, interactive=False,
robot='bert-e')


def _send(settings, pr, msg, exc_cls):
pr_utils._send_comment(settings, pr, msg,
exc_cls.dont_repeat_if_in_history,
updatable=exc_cls.updatable)


def test_updatable_classes():
assert exceptions.ResetComplete.updatable
assert exceptions.BranchHistoryMismatch.updatable
assert exceptions.IncorrectFixVersion.updatable
assert exceptions.IntegrationDataCreated.updatable
assert not exceptions.HelpMessage.updatable
assert not exceptions.StatusReport.updatable


def test_status_comment_is_updated(settings):
pr = FakePR()
_send(settings, pr, 'reset', exceptions.ResetComplete)
_send(settings, pr, 'history mismatch', exceptions.BranchHistoryMismatch)
assert len(pr.comments) == 1
assert pr.comments[0].text.startswith('history mismatch')
assert pr.comments[0].text.endswith(pr_utils.STATUS_MARKER)


def test_identical_status_not_reposted(settings):
pr = FakePR()
_send(settings, pr, 'reset', exceptions.ResetComplete)
with pytest.raises(exceptions.CommentAlreadyExists):
_send(settings, pr, 'reset', exceptions.ResetComplete)
assert len(pr.comments) == 1


def test_non_updatable_message_is_posted(settings):
pr = FakePR()
_send(settings, pr, 'reset', exceptions.ResetComplete)
_send(settings, pr, 'help', exceptions.HelpMessage)
assert len(pr.comments) == 2
assert pr_utils.STATUS_MARKER not in pr.comments[1].text


def test_fallback_when_edit_unsupported(settings):
pr = FakePR(editable=False)
_send(settings, pr, 'reset', exceptions.ResetComplete)
_send(settings, pr, 'other', exceptions.BranchHistoryMismatch)
assert len(pr.comments) == 2


def test_other_authors_comments_untouched(settings):
pr = FakePR()
pr.comments.append(FakeComment('someone', 'hi ' + pr_utils.STATUS_MARKER))
_send(settings, pr, 'reset', exceptions.ResetComplete)
assert len(pr.comments) == 2
assert pr.comments[0].text.startswith('hi')
34 changes: 32 additions & 2 deletions bert_e/workflow/pr_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@

LOG = logging.getLogger(__name__)

# Hidden marker identifying the comment holding the bot's latest status.
STATUS_MARKER = '<!-- bert-e:status -->'


def find_comment(pull_request: AbstractPullRequest, username=None,
startswith=None, max_history=None) -> AbstractComment:
Expand Down Expand Up @@ -50,15 +53,26 @@ def find_comment(pull_request: AbstractPullRequest, username=None,
return comment


def find_status_comment(pull_request: AbstractPullRequest,
username) -> AbstractComment:
"""Return the latest status comment posted by the bot, if any."""
for comment in reversed(pull_request.comments):
if comment.author == username and comment.text.rstrip().endswith(
STATUS_MARKER):
return comment


def _send_comment(settings, pull_request: AbstractPullRequest, msg: str,
dont_repeat_if_in_history=10) -> None:
dont_repeat_if_in_history=10, updatable=False) -> None:
"""Comment a pull request.

Before posting:
Check that the same comment was not already posted in the recent pull
request comments history.
Optionally (if settings.interactive is set) ask confirmation to the
user.
If `updatable` is set, edit the bot's previous status comment in place
(when there is one) instead of posting a new comment.

Raises:
CommentAlreadyExists: if the comment was already posted.
Expand All @@ -80,6 +94,21 @@ def _send_comment(settings, pull_request: AbstractPullRequest, msg: str,
if not confirm('Do you want to send this comment?'):
return

if updatable:
status_comment = find_status_comment(pull_request, settings.robot)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Warning — Blocking failures become invisible to PR authors.

find_status_comment returns the newest marked comment, however far back in the thread it is. Non-updatable messages such as ApprovalRequired, Conflict, BuildFailed and Queued are posted as new comments after it. So a new updatable failure, for example BranchHistoryMismatch or IncorrectFixVersion, silently rewrites a comment that may sit many comments above the current discussion, often the original greeting.

Git hosts do not notify anyone on comment edits. The PR author gets no visible message that their PR is now blocked, and the latest visible bot comment can be an outdated status.

msg = f'{msg}\n\n{STATUS_MARKER}'
if status_comment is not None:
if status_comment.text.strip() == msg.strip():
raise exceptions.CommentAlreadyExists(
"The status comment is already up to date."
)
try:
LOG.debug('UPDATING STATUS COMMENT %s', msg)
status_comment.edit(msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Critical — PRs loop on reset forever and can never merge.

handle_comments only treats commands posted after the bot's last comment as new: it walks reversed(pull_request.comments) and returns at the first comment by the robot (gitwaterflow/init.py ~L344). When a status is updatable, _send_comment now edits an older status comment instead of appending a new one. The bot then has no comment after the triggering command, so the command runs again on every later webhook.

Concrete case:

  1. A user posts @bert-e reset.
  2. _reset raises ResetComplete, which is updatable.
  3. The bot edits the InitMessage, which is the first comment and carries the marker. No new bot comment is added.
  4. On the next run handle_commands finds @bert-e reset again and deletes the integration branches again. It raises ResetComplete again, which is now swallowed as CommentAlreadyExists.

The flow stops at the reset on every run, so the PR never reaches integration or queuing. The same thing happens for any command whose answer is an updatable message.

return
except NotImplementedError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The except NotImplementedError only catches the case where the git host does not support editing. If the status comment was deleted by a user between find_status_comment() and edit(), an HTTP error (e.g. 404) would propagate uncaught and crash the notification flow instead of falling back to posting a new comment. Consider also catching requests.HTTPError (or a broader exception with a log warning) so the fallback works for API failures too.

— Claude Code

LOG.debug('Comments cannot be edited, posting a new one.')

LOG.debug('SENDING MESSAGE %s', msg)
pull_request.add_comment(msg)

Expand All @@ -104,6 +133,7 @@ def notify_user(settings, pull_request: AbstractPullRequest,
try:
_send_bot_status(settings, pull_request, comment)
_send_comment(settings, pull_request, str(comment),
comment.dont_repeat_if_in_history)
comment.dont_repeat_if_in_history,
updatable=comment.updatable)
except exceptions.CommentAlreadyExists:
LOG.info("Comment '%s' already posted", comment.__class__.__name__)
Loading