Skip to content
Open
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
22 changes: 22 additions & 0 deletions docs/hackbot/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ be turned back on for a recorded message by passing `unfurl=True` to `record_mes
`bugzilla.add_comment` appends a feedback-reaction footer to every recorded comment, and
`is_private=true` marks it security-group-only.

When hackbot-api posts a Bugzilla comment, it puts a front matter block at the top that
links the comment to its run:

```text
---
hackbot:
run: <run ID>
action: <action ID>
agent: <agent name>
url: https://hackbot.moz.tools/runs/<run ID>
---
```

BMO's Hackbot extension hides the block from readers, bugmail, webhooks and Pulse, and shows
the agent and a link to the run in the comment header instead. REST `raw_text` keeps it.
The block is added at apply time, not recorded, so a model can't write or change it and the
Hackbot UI and Slack never show it. It is left out when the action has no ID (summaries
recorded before action IDs existed) or when a value isn't an identifier, because BMO would
then show it as text. Agent text posted without the block (those comments, attachment
comments and new-bug descriptions) is moved down a line when it starts with a rule, so BMO
doesn't read a block the model wrote as the comment's metadata.

Adding a type is a declaration in the runtime's domain module plus one line in hackbot-api's
handler registry. The dispatch loop never changes.

Expand Down
10 changes: 7 additions & 3 deletions services/hackbot-api/app/action_handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ def __call__(self, key: str, max_bytes: int | None = None) -> Awaitable[bytes]:
class ApplyContext:
"""Everything an :class:`ActionHandler` needs from the run.

Scoped to a single action application (not the whole run), since only
``attachments`` varies per action. Handlers never talk to GCS directly —
``download_artifact`` is provided by the caller (hackbot-api's
Scoped to a single action application (not the whole run), since
``attachments`` and ``action_id`` vary per action. Handlers never talk to GCS
directly — ``download_artifact`` is provided by the caller (hackbot-api's
``/internal/events/apply-run-actions`` route) — so this package stays free of
a dependency on any particular storage backend. Async, matching
hackbot-api's own GCS wrappers and ``ActionHandler.apply`` itself.
Expand All @@ -47,6 +47,10 @@ class ApplyContext:
download_artifact: ArtifactDownloader
hackbot_client: HackbotClient
attachments: list[dict[str, str]] = field(default_factory=list)
# The `action_id` the runtime recorded for the action (`RunAction.ref`). None
# for summaries recorded before action IDs existed. When several actions are
# applied as one call, it is the one whose comment is posted.
action_id: str | None = None

def artifact_key(self, name: str) -> str | None:
"""The uploaded key for an attachment recorded under ``name``, if any."""
Expand Down
63 changes: 60 additions & 3 deletions services/hackbot-api/app/action_handlers/bugzilla_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import base64
import logging
import os
import re
from functools import lru_cache
from typing import Any

Expand Down Expand Up @@ -57,6 +58,52 @@ def _request(method: str, path: str, json_body: dict[str, Any]) -> dict[str, Any
return response.json()


# BMO's Hackbot extension reads this block off the top of comments from the
# Hackbot account, hides it, and shows the agent and a link to the run in the
# comment header instead. It reads only this shape: `run`, `action` and `agent`
# must be identifiers and `url` an https URL. Anything else shows as plain text,
# so a block that would not parse is left out rather than posted.
_IDENTIFIER = re.compile(r"[A-Za-z0-9._-]+")
Comment on lines +61 to +66

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we really need this? Could we make it dynamic so it accepts whatever we pass in? Or does it solve a technical limitation?



def _front_matter(ctx: ApplyContext) -> str:
"""The block that links a comment to the run and action that wrote it.

Added here rather than by the agent so a model can't write or change it.
Empty when the action has no ID (a summary recorded before action IDs).
"""
if ctx.action_id is None:
return ""
fields = {"run": ctx.run_id, "action": ctx.action_id, "agent": ctx.agent}
invalid = [key for key, value in fields.items() if not _IDENTIFIER.fullmatch(value)]
if invalid:
log.warning(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So we get a single about this in Sentry:

Suggested change
log.warning(
log.error(

Alternatively, we could raise an exception to fail loudly.

"Posting the comment without front matter: %s not an identifier",
", ".join(invalid),
)
return ""
url = f"{settings.hackbot_ui_url.rstrip('/')}/runs/{ctx.run_id}"
lines = [f" {key}: {value}" for key, value in fields.items()]
return "\n".join(["---", "hackbot:", *lines, f" url: {url}", "---", "", ""])


def _unparsed(text: str) -> str:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
def _unparsed(text: str) -> str:
def _escape_front_matter(text: str) -> str:

"""Agent text that BMO can't take for front matter.

BMO reads a block only at the very start of a comment, so text that starts
with a rule is moved down a line: a block the model wrote is then shown as
text rather than taken as the comment's metadata.
"""
return "\n" + text if text.startswith("---") else text


def _with_front_matter(comment: dict[str, Any], ctx: ApplyContext) -> dict[str, Any]:
"""The comment with its front matter, or with none that BMO would read."""
front_matter = _front_matter(ctx)
body = comment["body"] if front_matter else _unparsed(comment["body"])
return {**comment, "body": front_matter + body}


def _comment_body(params: dict[str, Any]) -> dict[str, Any]:
"""Build the ``comment`` object for a PUT /bug/{id}.

Expand All @@ -81,8 +128,14 @@ async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult
# comment (the coalescer hands us params built from other rows).
body = dict(params.get("changes", {}))
if params.get("comment"):
body["comment"] = params["comment"]
body["comment"] = _with_front_matter(params["comment"], ctx)
body["comment_tags"] = [ctx.agent]
elif isinstance(body.get("comment"), dict) and isinstance(
body["comment"].get("body"), str
):
# A comment the agent put in `changes` itself, which is also posted
# from the Hackbot account.
body["comment"] = _with_front_matter(body["comment"], ctx)
Comment on lines +131 to +138

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We could simplify it be checking for body.get("comment") after if params.get("comment"):

if params.get("comment"):
    body["comment"] = params["comment"]
    body["comment_tags"] = [ctx.agent]

if body.get("comment"):
    body["comment"] = _with_front_matter(body["comment"], ctx)

try:
_request("PUT", f"bug/{bug_id}", body)
except Exception as exc:
Expand All @@ -94,7 +147,8 @@ async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult
class AddCommentHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
bug_id = params["bug_id"]
body: dict[str, Any] = {"comment": _comment_body(params)}
comment = _with_front_matter(_comment_body(params), ctx)
body: dict[str, Any] = {"comment": comment}
body["comment_tags"] = [ctx.agent]
try:
_request("PUT", f"bug/{bug_id}", body)
Expand Down Expand Up @@ -126,7 +180,7 @@ async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult
"is_patch": params.get("is_patch", False),
}
if params.get("comment"):
body["comment"] = params["comment"]
body["comment"] = _unparsed(params["comment"])

try:
data = _request("POST", f"bug/{bug_id}/attachment", body)
Expand All @@ -146,6 +200,9 @@ async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult

class CreateBugHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
# The description is comment 0, which gets no front matter.
if isinstance(params.get("description"), str):
params = {**params, "description": _unparsed(params["description"])}
try:
data = _request("POST", "bug", params)
except Exception as exc:
Expand Down
10 changes: 9 additions & 1 deletion services/hackbot-api/app/actions_applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ async def _dispatch(
params: dict,
attachments: list[dict],
hackbot_client: HackbotClient,
action_id: str | None,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we use id instead of ref that would be int instead of str and it will never be None.

) -> ActionResult:
"""Run one handler call, converting failures into a failed `ActionResult`.

Expand All @@ -184,6 +185,7 @@ async def _dispatch(
attachments=attachments,
agent=run.agent,
hackbot_client=hackbot_client,
action_id=action_id,
)
try:
return await handler.apply(params, ctx)
Expand Down Expand Up @@ -303,18 +305,24 @@ async def _apply_pending_rows(
(member.type, resolve_placeholders(member.params, results_by_ref))
for member in member_rows
]
# The group carries at most one comment, which keeps its own ID.
comment_id = next(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
comment_id = next(
comment_action_id = next(

(m.ref for m in member_rows if m.type == "bugzilla.add_comment"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

None,
)
outcome = await _dispatch(
run,
"bugzilla.update_bug",
merge_resolved(entries),
[],
hackbot_client,
comment_id,
)
else:
member_rows = [row]
params = resolve_placeholders(row.params, results_by_ref)
outcome = await _dispatch(
run, row.type, params, attachments, hackbot_client
run, row.type, params, attachments, hackbot_client, row.ref

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should pass the id instead of the ref:

Suggested change
run, row.type, params, attachments, hackbot_client, row.ref
run, row.type, params, attachments, hackbot_client, row.id

)

# Only stamp applied_at on a real success, so a failed row isn't
Expand Down
44 changes: 44 additions & 0 deletions services/hackbot-api/tests/test_actions_applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,50 @@ async def test_extra_comments_applied_separately(monkeypatch):
]


class _ContextRecordingHandler:
def __init__(self):
self.action_ids = []

async def apply(self, params, ctx):
self.action_ids.append(ctx.action_id)
return SimpleNamespace(status="applied", result={}, error=None)


async def test_handlers_get_the_action_id_of_the_comment_they_post(monkeypatch):
"""A coalesced PUT carries its comment's ID, which goes in the front matter."""
handler = _ContextRecordingHandler()
monkeypatch.setattr(actions_applier, "get_handler", lambda t: handler)

update = _row(
0,
"pending",
action_type="bugzilla.update_bug",
params={"bug_id": 5, "changes": {"status": "RESOLVED"}},
ref="update-id",
)
comment = _row(
1,
"pending",
action_type="bugzilla.add_comment",
params={"bug_id": 5, "text": "done"},
ref="comment-id",
)
other = _row(
2,
"pending",
action_type="bugzilla.add_comment",
params={"bug_id": 99, "text": "different bug"},
ref="other-id",
)
rows = [(update, []), (comment, []), (other, [])]

await actions_applier._apply_pending_rows(
_FakeDB(), _FakeRun(status=RunStatus.succeeded.value), rows, _CLIENT
)

assert handler.action_ids == ["comment-id", "other-id"]


async def test_lone_same_type_actions_on_different_bugs_not_merged(monkeypatch):
handler = _RecordingHandler(
SimpleNamespace(status="applied", result={}, error=None)
Expand Down
Loading