From 75c16e2cacad34cbcbd417edf4d31b5757f5790f Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Wed, 22 Jul 2026 10:59:07 -0700 Subject: [PATCH 1/2] feat: emit github.pr.opened / github.pr.merged on the event bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broadcast PR lifecycle events (ADR 0039) so consumers can surface them without polling — e.g. protoAgent's Fleet Room activity feed rendering "opened PR #123 · " for a member agent. - get_write_tools() takes an optional `emit` (the host's namespaced registry.emit). github_create_pr emits `pr.opened` {repo, number, title, url, head, base}; github_merge_pr emits `pr.merged` {repo, number, method}. The registry namespaces them to `github.*`. - register() passes `emit=getattr(registry, "emit", None)` — hasattr-guarded like the plugin's other host couplings, so an older host without the seam still loads the tools, just without events. - _emit swallows everything: telemetry can never fail a write tool. Tests: 91 passed. --- __init__.py | 7 ++++++- write_tools.py | 31 ++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index bf1fb0c..d0484fc 100644 --- a/__init__.py +++ b/__init__.py @@ -58,7 +58,12 @@ def register(registry) -> None: from .review_tools import get_review_tools from .write_tools import get_write_tools - write = get_write_tools(default_repo) + get_review_tools(default_repo) + # Pass the host's event-bus seam (ADR 0039) when it exists so PR lifecycle + # events broadcast as `github.pr.opened` / `github.pr.merged`. hasattr-guarded + # like the other host couplings — an older host just gets no events. + write = get_write_tools(default_repo, emit=getattr(registry, "emit", None)) + get_review_tools( + default_repo + ) for t in write: registry.register_tool(t) n_write = len(write) diff --git a/write_tools.py b/write_tools.py index 1e28d26..1e9c29f 100644 --- a/write_tools.py +++ b/write_tools.py @@ -36,9 +36,28 @@ def _csv(value: str) -> list[str]: return [tok.strip() for tok in (value or "").split(",") if tok.strip()] -def get_write_tools(default_repo: str = "") -> list: +def _pr_number(url: str) -> str: + """The PR number from a ``…/pull/<n>`` URL, or "" when it isn't one.""" + tail = (url or "").rstrip("/").rsplit("/", 1)[-1] + return tail if tail.isdigit() else "" + + +def get_write_tools(default_repo: str = "", emit=None) -> list: """Build the write tools. ``default_repo`` (``owner/name``) is used whenever a tool's - ``repo`` arg is omitted, so an agent with one configured repo needn't repeat it.""" + ``repo`` arg is omitted, so an agent with one configured repo needn't repeat it. + + ``emit`` is the host's namespaced event-bus seam (ADR 0039; ``registry.emit``). When + given, PR lifecycle events are broadcast as ``github.pr.opened`` / ``github.pr.merged`` + so consumers (e.g. a fleet activity feed) can surface them without polling. Optional + and fully guarded — an older host, or a bus error, never fails a tool.""" + + def _emit(topic: str, data: dict) -> None: + if not emit: + return + try: + emit(topic, data) + except Exception: # noqa: BLE001 — telemetry must never break a write tool + pass @tool async def github_create_issue(title: str, repo: str = "", body: str = "", labels: str = "") -> str: @@ -118,7 +137,12 @@ async def github_create_pr(head: str, title: str, repo: str = "", body: str = "" rc, out, serr = await run_gh(args) if gh_err := check_gh_error(rc, serr): return gh_err - return out.strip() + url = out.strip() + _emit( + "pr.opened", + {"repo": repo, "number": _pr_number(url), "title": title, "url": url, "head": head, "base": base}, + ) + return url @tool async def github_edit_pr(number: int, repo: str = "", title: str = "", body: str = "", state: str = "") -> str: @@ -203,6 +227,7 @@ async def github_merge_pr( rc, out, serr = await run_gh(args) if gh_err := check_gh_error(rc, serr): return gh_err + _emit("pr.merged", {"repo": repo, "number": str(number), "method": method}) return out.strip() or f"Merged PR #{number} in {repo} via {method}." @tool From 8d6733010dd059107bdf8740a60803452914f382 Mon Sep 17 00:00:00 2001 From: Josh Mabry <mabry1985@gmail.com> Date: Wed, 22 Jul 2026 11:39:04 -0700 Subject: [PATCH 2/2] style: ruff format the rebased register() call site The conflict resolution (keeping main's `+ get_review_tools(...)` alongside the new `emit=` arg) was hand-wrapped; ruff format wants it on one line. `ruff format --check` is a CI gate, so this unblocks the run. No behavior change. --- __init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index d0484fc..4460609 100644 --- a/__init__.py +++ b/__init__.py @@ -61,9 +61,7 @@ def register(registry) -> None: # Pass the host's event-bus seam (ADR 0039) when it exists so PR lifecycle # events broadcast as `github.pr.opened` / `github.pr.merged`. hasattr-guarded # like the other host couplings — an older host just gets no events. - write = get_write_tools(default_repo, emit=getattr(registry, "emit", None)) + get_review_tools( - default_repo - ) + write = get_write_tools(default_repo, emit=getattr(registry, "emit", None)) + get_review_tools(default_repo) for t in write: registry.register_tool(t) n_write = len(write)