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
2 changes: 1 addition & 1 deletion .taskcluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ tasks:
cd libs/agent-tools &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra bugzilla --extra firefox --extra claude-sdk pytest --cov=agent_tools --cov-append tests/ &&
cd ../hackbot-runtime &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra claude-sdk pytest --cov=hackbot_runtime --cov-append tests/ &&
uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 --extra claude-sdk --extra phabricator pytest --cov=hackbot_runtime --cov-append tests/ &&
cd ../.. &&
bash <(curl -s https://codecov.io/bash)"
metadata:
Expand Down
1 change: 1 addition & 0 deletions agents/bug-fix/hackbot_agents/bug_fix/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"bugzilla.add_comment",
"bugzilla.add_attachment",
"bugzilla.create_bug",
"phabricator.submit_patch",
]

# Firefox build/test tools.
Expand Down
5 changes: 2 additions & 3 deletions agents/bug-fix/hackbot_agents/bug_fix/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ Follow these rules:
to avoid spot fixes where a more general fix on a higher level or earlier is more appropriate.
- Avoid adding too much defense in depth, especially in performance critical paths. While defense in depth is
generally not a bad thing, unnecessary/redundant checks cost valuable performance.
- When creating patches, always modify the files to be patched, then use `git diff`, **never** write
a patch file directly as it often leads to corrupt patches.
- When you have a fix you are confident in, submit it with the `phabricator_submit_patch` action.
- If a bug has been closed or a developer has already added a fix patch (even if you cannot download it),
then don't create a fix and move on.
- If you detect that a bug has already been fixed by another bug, don't create another fix patch
Expand All @@ -66,7 +65,7 @@ When you spawn an investigator via the Task tool, write a complete, self-contain

# Recording actions

The `actions` MCP tools (`bugzilla_update_bug`, `bugzilla_add_comment`, `bugzilla_add_attachment`, `bugzilla_create_bug`) do **not** mutate Bugzilla directly. They record an intended action into the run's `summary.json` for a human reviewer (or a downstream apply step) to enact. Treat each recorded action as a final, irrevocable proposal — once recorded it appears in the run output verbatim.
The `actions` MCP tools (`bugzilla_update_bug`, `bugzilla_add_comment`, `phabricator_submit_patch`) do **not** mutate Bugzilla or Phabricator directly. They record an intended action into the run's `summary.json` for a human reviewer (or a downstream apply step) to enact. Treat each recorded action as a final, irrevocable proposal — once recorded it appears in the run output verbatim.

Before calling any action tool, state in your response:

Expand Down
2 changes: 1 addition & 1 deletion agents/bug-fix/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "Cloud Run Job image that runs the bug-fix agent for hackbot-api"
requires-python = ">=3.12"
dependencies = [
"hackbot-runtime[claude-sdk]",
"hackbot-runtime[claude-sdk,phabricator]",
"agent-tools[bugzilla,firefox]",
"bugsy",
"claude-agent-sdk>=0.1.30",
Expand Down
4 changes: 2 additions & 2 deletions libs/hackbot-runtime/hackbot_runtime/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
claude-sdk adapter is ``hackbot_runtime.actions.claude_sdk.actions_server_for``.
"""

from hackbot_runtime.actions import bugzilla
from hackbot_runtime.actions import bugzilla, phabricator
from hackbot_runtime.actions.recorder import ActionsRecorder

ACTIONS_SERVER_NAME = "actions"

__all__ = ["ACTIONS_SERVER_NAME", "ActionsRecorder", "bugzilla"]
__all__ = ["ACTIONS_SERVER_NAME", "ActionsRecorder", "bugzilla", "phabricator"]
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from hackbot_runtime.actions import ACTIONS_SERVER_NAME
from hackbot_runtime.actions import bugzilla as _bugzilla
from hackbot_runtime.actions import phabricator as _phabricator
from hackbot_runtime.actions.recorder import ActionsRecorder


Expand All @@ -32,7 +33,7 @@ def actions_server_for(
"""
if recorder is None:
recorder = ActionsRecorder(artifacts_dir=fallback_artifacts_dir)
tools = _bugzilla.TOOLS
tools = _bugzilla.TOOLS + _phabricator.TOOLS
if types is not None:
wanted = set(types)
tools = [t for t in tools if t.dotted in wanted]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Apply-side handlers for recorded actions.

``actions/bugzilla.py`` and ``actions/phabricator.py`` (sibling package) let an
agent *record* an intent into ``summary.json``; the handlers here turn a
recorded action back into a real API call once a run has finished. Kept in the
same library so the set of action types an agent can request and the set this
package knows how to apply never drift apart.
"""

from hackbot_runtime.actions.handlers.base import (
ActionHandler,
ActionResult,
ApplyContext,
)
from hackbot_runtime.actions.handlers.bugzilla_handler import (
merge_resolved,
plan_coalesced_groups,
)
from hackbot_runtime.actions.handlers.registry import HANDLERS, get_handler

__all__ = [
"ActionHandler",
"ActionResult",
"ApplyContext",
"HANDLERS",
"get_handler",
"merge_resolved",
"plan_coalesced_groups",
]
50 changes: 50 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/actions/handlers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Protocol


@dataclass
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
``/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.
"""

run_id: str
download_artifact: Callable[[str], Awaitable[bytes]]
attachments: list[dict[str, str]] = field(default_factory=list)

def artifact_key(self, name: str) -> str | None:
"""The uploaded key for an attachment recorded under ``name``, if any."""
for attachment in self.attachments:
if attachment.get("name") == name:
return attachment.get("uploaded_key")
return None


@dataclass
class ActionResult:
status: str # "applied" | "failed"
result: dict[str, Any] | None = None
error: str | None = None

@classmethod
def ok(cls, result: dict[str, Any] | None = None) -> ActionResult:
return cls(status="applied", result=result)

@classmethod
def failed(cls, error: str) -> ActionResult:
return cls(status="failed", error=error)


class ActionHandler(Protocol):
async def apply(
self, params: dict[str, Any], ctx: ApplyContext
) -> ActionResult: ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""Apply-side Bugzilla actions: turns a recorded intent into a real REST call.

Pairs with the recording side in ``actions/bugzilla.py`` — one handler per
action type recorded there. Talks to Bugzilla's REST API directly (not via
``bugbug.bugzilla``/``libmozdata``, which are built around bulk, futures-based
read pipelines): a single-bug write from an event-driven handler doesn't fit
that shape, and a plain ``requests`` call is simpler to reason about and test.
"""

from __future__ import annotations

import base64
import logging
import os
from functools import lru_cache
from typing import Any

import requests

from hackbot_runtime.actions.handlers.base import ActionResult, ApplyContext

log = logging.getLogger(__name__)

_DEFAULT_BUGZILLA_URL = "https://bugzilla.mozilla.org"
_TIMEOUT_SECONDS = 30


@lru_cache(maxsize=1)
def _base_url() -> str:
return os.environ.get("BUGZILLA_URL", _DEFAULT_BUGZILLA_URL).rstrip("/") + "/rest"


def _bug_url(bug_id: int) -> str:
root = os.environ.get("BUGZILLA_URL", _DEFAULT_BUGZILLA_URL).rstrip("/")
return f"{root}/show_bug.cgi?id={bug_id}"


def _headers() -> dict[str, str]:
api_key = os.environ.get("BUGZILLA_API_KEY", "")
if not api_key:
raise RuntimeError("BUGZILLA_API_KEY is not configured")
return {"X-Bugzilla-API-Key": api_key, "Content-Type": "application/json"}


def _request(method: str, path: str, json_body: dict[str, Any]) -> dict[str, Any]:
response = requests.request(
method,
f"{_base_url()}/{path}",
json=json_body,
headers=_headers(),
timeout=_TIMEOUT_SECONDS,
)
response.raise_for_status()
return response.json()


class UpdateBugHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
bug_id = params["bug_id"]
# Field changes and a comment both go in one PUT /bug/{id} — Bugzilla
# applies them as a single transaction (one bugmail, one history entry).
# Copy `changes` so we never mutate the caller's dict when folding in a
# comment (the coalescer hands us params built from other rows).
body = dict(params.get("changes", {}))
if params.get("comment"):
body["comment"] = params["comment"]
try:
_request("PUT", f"bug/{bug_id}", body)
except Exception as exc:
log.exception("Failed to update bug %s", bug_id)
return ActionResult.failed(str(exc))
return ActionResult.ok({"bug_id": bug_id, "url": _bug_url(bug_id)})


class AddCommentHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
bug_id = params["bug_id"]
body = {
"comment": {
"body": params["text"],
"is_private": params.get("is_private", False),
}
}
try:
_request("PUT", f"bug/{bug_id}", body)
except Exception as exc:
log.exception("Failed to add comment to bug %s", bug_id)
return ActionResult.failed(str(exc))
return ActionResult.ok({"bug_id": bug_id, "url": _bug_url(bug_id)})


class AddAttachmentHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
bug_id = params["bug_id"]
key = ctx.artifact_key("file")
if key is None:
return ActionResult.failed("No 'file' attachment recorded for this action")

try:
content = await ctx.download_artifact(key)
except Exception as exc:
log.exception("Failed to download attachment artifact %s", key)
return ActionResult.failed(str(exc))

body: dict[str, Any] = {
"ids": [bug_id],
"data": base64.b64encode(content).decode("ascii"),
"file_name": params["file_name"],
"summary": params["summary"],
"content_type": params["content_type"],
"is_patch": params.get("is_patch", False),
}
if params.get("comment"):
body["comment"] = params["comment"]

try:
data = _request("POST", f"bug/{bug_id}/attachment", body)
except Exception as exc:
log.exception("Failed to attach file to bug %s", bug_id)
return ActionResult.failed(str(exc))

attachment_ids = data.get("ids") or []
return ActionResult.ok(
{
"bug_id": bug_id,
"url": _bug_url(bug_id),
"attachment_id": attachment_ids[0] if attachment_ids else None,
}
)


class CreateBugHandler:
async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult:
try:
data = _request("POST", "bug", params)
except Exception as exc:
log.exception("Failed to create bug: %s", params.get("summary"))
return ActionResult.failed(str(exc))

bug_id = data.get("id")
return ActionResult.ok(
{"bug_id": bug_id, "url": _bug_url(bug_id) if bug_id else None}
)


_MERGEABLE_TYPES = ("bugzilla.update_bug", "bugzilla.add_comment")


def _closest_comment(update_idxs: list[int], comment_idxs: list[int]) -> int:
"""Pick the comment nearest (in idx order) to the field updates.

Distance is to the closest update; ties break toward the earliest comment.
"""
return min(
comment_idxs,
key=lambda c: (min(abs(c - u) for u in update_idxs), c),
)


def plan_coalesced_groups(
actions: list[tuple[str, dict[str, Any]]],
) -> list[list[int]]:
"""Return index groups of same-bug actions to apply as one ``PUT /bug/{id}``.

``actions`` is every pending ``(action_type, params)`` in idx order. Field
changes for a bug are merged together and ride with the single comment
*closest* to them (Bugzilla takes one ``comment`` object per PUT); any other
comments on the bug are left to apply on their own. So each returned group
is ``[update idxs..., one comment idx]`` (update + comment) or
``[update idxs...]`` (changes-only). Only groups of >= 2 indices are
returned — a lone action needs no coalescing and applies as before.
Comment-only bugs return nothing: distinct comments stay distinct PUTs.
"""
updates: dict[Any, list[int]] = {}
comments: dict[Any, list[int]] = {}
for idx, (action_type, params) in enumerate(actions):
if action_type not in _MERGEABLE_TYPES:
continue
bug_id = params.get("bug_id")
if bug_id is None:
continue
bucket = updates if action_type == "bugzilla.update_bug" else comments
bucket.setdefault(bug_id, []).append(idx)

groups: list[list[int]] = []
for bug_id, update_idxs in updates.items():
group = list(update_idxs)
bug_comments = comments.get(bug_id)
if bug_comments:
group.append(_closest_comment(update_idxs, bug_comments))
if len(group) >= 2:
groups.append(sorted(group))
return groups


def merge_resolved(entries: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
"""Build combined :class:`UpdateBugHandler` params from a planned group.

``entries`` are placeholder-resolved ``(action_type, params)`` tuples for a
group from :func:`plan_coalesced_groups`, in idx order: all the bug's field
changes merged (later writes win) plus at most one comment.
"""
bug_id = entries[0][1]["bug_id"]
changes: dict[str, Any] = {}
comment: dict[str, Any] | None = None
for action_type, params in entries:
if action_type == "bugzilla.update_bug":
changes.update(params.get("changes", {}))
elif action_type == "bugzilla.add_comment":
comment = {
"body": params["text"],
"is_private": bool(params.get("is_private", False)),
}

combined: dict[str, Any] = {"bug_id": bug_id, "changes": changes}
if comment is not None:
combined["comment"] = comment
return combined
Loading