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
21 changes: 21 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 this message is a transient status that should edit the
# previous status comment instead of posting a new one
updatable = False

def __init__(self, **kwargs):
self.kwargs = kwargs
Expand Down Expand Up @@ -93,78 +96,91 @@ class StatusReport(TemplateException):

class IncompatibleSourceBranchPrefix(TemplateException):
code = 106
updatable = True
template = 'incompatible_source_branch_prefix.md'
status = "failure"


class MissingJiraId(TemplateException):
code = 107
updatable = True
template = 'missing_jira_id.md'
status = "failure"


class JiraIssueNotFound(TemplateException):
code = 108
updatable = True
template = 'jira_issue_not_found.md'
status = "failure"


class IssueTypeNotSupported(TemplateException):
code = 109
updatable = True
template = 'issue_type_not_supported.md'
status = "failure"


class IncorrectJiraProject(TemplateException):
code = 110
updatable = True
template = 'incorrect_jira_project.md'
status = "failure"


class MismatchPrefixIssueType(TemplateException):
code = 111
updatable = True
template = 'mismatch_prefix_issue_type.md'
status = "failure"


class IncorrectFixVersion(TemplateException):
code = 112
updatable = True
template = 'incorrect_fix_version.md'
status = "failure"


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


class Conflict(TemplateException):
code = 114
updatable = True
template = 'conflict.md'
status = "failure"


class ApprovalRequired(TemplateException):
code = 115
updatable = True
template = 'need_approval.md'
status = "queued"


class BuildFailed(TemplateException):
code = 118
updatable = True
template = 'build_failed.md'
status = "failure"


class AfterPullRequest(TemplateException):
code = 120
updatable = True
template = 'after_pull_request.md'
status = "queued"


class IntegrationDataCreated(InformationException):
code = 121
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 list of integration branches and PRs Bert-E created.

IntegrationDataCreated is marked updatable, but it is sent from notify_integration_data just before check_approvals and check_build_status run in the same handle. Those almost always raise ApprovalRequired, BuildFailed or AfterPullRequest, which are also updatable, and they immediately overwrite it. So the one message listing the newly created integration branches and child PRs is erased within the same run. That is why the PR had to drop the 'Integration data created' assertion in test_creation_integration_branch_by_approve. The message is informational (NEVER_REPEAT), not a transient status, and it is only sent when branches are newly created, so it will never be shown again.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

IntegrationDataCreated is followed right away by another status in the same run (for example ApprovalRequired), so the list of created integration branches and PRs gets overwritten before anyone sees it. The updated test_creation_integration_branch_by_approve shows this: the assertion for 'Integration data created' was removed. Keep this message as its own comment.

Suggested change
updatable = True
updatable = False



— Claude Code

template = 'integration_data_created.md'


Expand All @@ -182,6 +198,7 @@ class NotEnoughCredentials(TemplateException):

class QueueConflict(TemplateException):
code = 124
updatable = True
template = "queue_conflict.md"
status = "failure"

Expand Down Expand Up @@ -225,11 +242,13 @@ class QueueOutOfOrder(TemplateException):

class ResetComplete(TemplateException):
code = 128
updatable = True
template = "reset_complete.md"


class LossyResetWarning(TemplateException):
code = 129
updatable = True
template = "lossy_reset.md"
status = "failure"

Expand All @@ -248,6 +267,7 @@ class IncorrectPullRequestNumber(TemplateException):

class SourceBranchTooOld(TemplateException):
code = 132
updatable = True
template = "source_branch_too_old.md"
status = "failure"

Expand All @@ -266,6 +286,7 @@ class NotAuthor(TemplateException):

class RequestIntegrationBranches(TemplateException):
code = 135
updatable = True
template = "request_integration_branches.md"
# TODO: review if it should be failure.
status = "queued"
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 @@ -257,6 +257,13 @@
def delete(self) -> None:
"""Delete the comment."""

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

Raises NotImplementedError if the git host cannot edit comments.
"""
raise NotImplementedError('Comment edition is not supported')

Check warning on line 265 in bert_e/git_host/base.py

View check run for this annotation

Codecov / codecov/patch

bert_e/git_host/base.py#L265

Added line #L265 was not covered by tests

@property
@abstractmethod
def author(self) -> str:
Expand Down
4 changes: 4 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,10 @@
def delete(self) -> None:
self.client.delete(self.data['url'])

def update(self, msg: str) -> None:
self.data = self.client.patch(

Check warning on line 1022 in bert_e/git_host/github/__init__.py

View check run for this annotation

Codecov / codecov/patch

bert_e/git_host/github/__init__.py#L1022

Added line #L1022 was not covered by tests

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Minor — A failed edit aborts the job instead of falling back to a new comment.

Comment.update calls client.patch. That method actually sends session.post (existing code at line 272), so the edit only works if GitHub keeps accepting POST in place of PATCH. Any HTTP error from it (for example a 404 because the comment was deleted after pull_request.comments was read, or a 403 or 422) is a requests.HTTPError, not NotImplementedError. _send_comment only catches NotImplementedError, so the error escapes notify_user and aborts the job instead of falling back to add_comment. No test covers the GitHub implementation; only the mock and fakes are tested.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

client.patch() uses self.session.post() (copy-pasted from the post method) instead of self.session.patch(). This is a pre-existing bug, but Comment.update is the first caller of client.patch(), so editing comments will send HTTP POST instead of PATCH to GitHub's API — which will fail at runtime.

The error propagates as requests.HTTPError, which is not caught by the except NotImplementedError in _send_comment, so the workflow will crash instead of gracefully falling back to creating a new comment.

suggestion<br> self.data = self.client.patch(<br>

You also need to fix client.patch() itself (line 272 of this file) to use self.session.patch(...) instead of self.session.post(...).

— Claude Code

self.data['url'], data=json.dumps({'body': msg}))


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

def update(self, msg):
self.controlled.content = {"raw": msg, "markup": "markdown",
"html": msg}

@property
def author(self):
return self['user']['username'].lower()
Expand Down
10 changes: 5 additions & 5 deletions bert_e/tests/test_bert_e.py
Original file line number Diff line number Diff line change
Expand Up @@ -1493,10 +1493,8 @@ def test_creation_integration_branch_by_approve(self):
with self.assertRaises(exns.ApprovalRequired):
self.handle(pr.id, options=options, backtrace=True)

self.assertEqual(len(list(pr.get_comments())), 3)

self.assertIn(
'Integration data created', list(pr.get_comments())[-2].text)
# the status comment is updated in place
self.assertEqual(len(list(pr.get_comments())), 2)

self.assertIn(
'Waiting for approval', self.get_last_pr_comment(pr))
Expand Down Expand Up @@ -2250,7 +2248,9 @@ def get_last_comment(pr):
returns the md5 digest of the last comment for easier comparison.

"""
return md5(list(pr.get_comments())[-1].text.encode()).digest()
text = list(pr.get_comments())[-1].text
text = text.replace('\n<!-- bert-e-status -->', '')
return md5(text.encode()).digest()

pr = self.create_pr('bugfix/TEST-01334', 'development/4.3',
file_='toto.txt')
Expand Down
115 changes: 115 additions & 0 deletions bert_e/tests/unit/test_comment_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Transient status messages edit the previous status comment."""
from types import SimpleNamespace

import pytest

from bert_e import exceptions
from bert_e.workflow import pr_utils
from bert_e.workflow.pr_utils import STATUS_MARKER, notify_user


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

def update(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))

def set_bot_status(self, *args, **kwargs):
pass

Check warning on line 32 in bert_e/tests/unit/test_comment_update.py

View check run for this annotation

Codecov / codecov/patch

bert_e/tests/unit/test_comment_update.py#L32

Added line #L32 was not covered by tests


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


class FakeStatus(Exception):
status = None
title = 'title'
dont_repeat_if_in_history = 0
updatable = True

def __init__(self, text):
super().__init__(text)
self.text = text

def __str__(self):
return self.text


def test_updatable_flag():
assert exceptions.BranchHistoryMismatch.updatable
assert exceptions.Conflict.updatable
assert not exceptions.HelpMessage.updatable
assert not exceptions.SuccessMessage.updatable


def test_status_comment_is_edited_in_place(settings):
pr = FakePR()
pr_utils._send_comment(settings, pr, 'first', 0, updatable=True)
pr_utils._send_comment(settings, pr, 'second', 0, updatable=True)
assert len(pr.comments) == 1
assert pr.comments[0].text == f'second\n{STATUS_MARKER}'


def test_same_status_is_not_republished(settings):
pr = FakePR()
pr_utils._send_comment(settings, pr, 'same', 0, updatable=True)
with pytest.raises(exceptions.CommentAlreadyExists):
pr_utils._send_comment(settings, pr, 'same', 0, updatable=True)
assert len(pr.comments) == 1


def test_fallback_to_new_comment_when_edit_unsupported(settings):
pr = FakePR(editable=False)
pr_utils._send_comment(settings, pr, 'first', 0, updatable=True)
pr_utils._send_comment(settings, pr, 'second', 0, updatable=True)
assert len(pr.comments) == 2


def test_non_updatable_messages_still_create_comments(settings):
pr = FakePR()
pr_utils._send_comment(settings, pr, 'one', 0)
pr_utils._send_comment(settings, pr, 'two', 0)
assert [c.text for c in pr.comments] == ['one', 'two']


def test_other_authors_comments_are_not_edited(settings):
pr = FakePR()
pr.comments.append(FakeComment('someone', f'x\n{STATUS_MARKER}'))
pr_utils._send_comment(settings, pr, 'new', 0, updatable=True)
assert len(pr.comments) == 2
assert pr.comments[0].text == f'x\n{STATUS_MARKER}'


def test_notify_user_uses_exception_flag(settings):
pr = FakePR()
notify_user(settings, pr, FakeStatus('history conflict'))
notify_user(settings, pr, FakeStatus('reset'))
assert len(pr.comments) == 1
assert pr.comments[0].text.startswith('reset')


def test_status_comment_not_edited_after_user_comment(settings):
"""Commands posted after the status must still be seen as new."""
pr = FakePR()
pr_utils._send_comment(settings, pr, 'first', 0, updatable=True)
pr.comments.append(FakeComment('user', '@bert-e reset'))
pr_utils._send_comment(settings, pr, 'second', 0, updatable=True)
assert len(pr.comments) == 3
assert pr.comments[0].text == f'first\n{STATUS_MARKER}'
32 changes: 30 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 single, continuously edited status comment.
STATUS_MARKER = '<!-- bert-e-status -->'


def find_comment(pull_request: AbstractPullRequest, username=None,
startswith=None, max_history=None) -> AbstractComment:
Expand Down Expand Up @@ -51,7 +54,7 @@ def find_comment(pull_request: AbstractPullRequest, username=None,


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:
Expand Down Expand Up @@ -80,6 +83,30 @@ def _send_comment(settings, pull_request: AbstractPullRequest, msg: str,
if not confirm('Do you want to send this comment?'):
return

if updatable:
msg = f'{msg}\n{STATUS_MARKER}'

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 — Bitbucket users see stray HTML markup in every Bert-E status comment.

The <!-- bert-e-status --> marker is added to every updatable message before the code knows whether the host can edit comments. On Bitbucket, bitbucket.Comment has no update, so previous.update raises NotImplementedError and the message is posted with add_comment(msg), marker included. Bitbucket Cloud markdown does not support raw HTML, so the marker shows up as literal text at the bottom of every status comment (history mismatch, conflict, approval required, and so on). Bitbucket gets nothing in return, because it still posts a new comment each time.

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 marker is added even when the host cannot edit comments. Bitbucket's Comment has no update(), so it always gets a new comment, but that comment still carries the <!-- bert-e-status --> marker. Bitbucket may not hide raw HTML comments the way GitHub does. Add the marker only when the host supports editing, for example with a capability flag on the comment or PR class.

— Claude Code

# Only edit the status comment if it is the last comment of the
# pull request: commands are looked up in the comments posted after
# Bert-E's last one, so editing an older comment would make already
# handled commands look new again.
comments = pull_request.comments
previous = comments[-1] if comments else None
if previous is not None:
is_status = (previous.author == settings.robot and
STATUS_MARKER in previous.text)
if not is_status:
previous = None
if previous is not None:
if previous.text == msg:
raise exceptions.CommentAlreadyExists(
"The status comment is already up to date.")
try:
LOG.debug('UPDATING MESSAGE %s', msg)
previous.update(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.

🟡 Warning — Authors silently miss build failures and conflicts that block their PR.

When the previous status is the last comment, a new status such as BuildFailed, Conflict or QueueConflict replaces ApprovalRequired through a comment edit. GitHub sends no notification or email for an edited comment, so the PR author and subscribers are no longer told when the PR moves into a failure state. Before this change, each status was a new comment and triggered a notification.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

GitHub does not send notifications when a comment is edited. With this change, a PR author will no longer be notified when the status moves to Build failed, Conflict, History mismatch or Waiting for approval, which are the statuses they need to act on.

Consider editing in place only when the new status has the same class as the previous one, or keep the actionable failure statuses as new comments.

— Claude Code

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 is too narrow. If the GitHub API call fails (e.g. 403, 404, rate-limited), requests.HTTPError is raised, which will not be caught here — crashing notify_user instead of falling back to a new comment.

```suggestion
except Exception:

pass

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

Expand All @@ -104,6 +131,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,
getattr(comment, 'updatable', False))
except exceptions.CommentAlreadyExists:
LOG.info("Comment '%s' already posted", comment.__class__.__name__)
Loading