Skip to content
Merged
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
24 changes: 22 additions & 2 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,11 @@ gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 jobs --limit 20

Jobs include `trigger_actor` and `trigger_actor_avatar_url` when the bridge can
identify the GitHub user that caused the notification. New GitHub notification
jobs derive the login from the notification sender and use GitHub's avatar URL.
Existing jobs can be backfilled from stored GitHub context:
jobs first resolve the parsed issue, pull request, comment, review, commit
comment, or workflow run through the GitHub API and store that resource author.
If that lookup is unavailable, enqueue falls back to the notification sender
display name when GitHub provides a concrete login there. Existing jobs can be
backfilled from stored GitHub context:

```bash
gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 \
Expand All @@ -251,6 +254,23 @@ gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 \
backfill-trigger-actors
```

### Detect install drift

Set `GITHUB_AGENT_BRIDGE_RELEASE_REPO` in the systemd environment file to the
repository that publishes bridge releases. `gab monitor` reports the installed
package version, fetches the latest published GitHub release, and alerts when a
newer release is available. The alert includes the release URL and the first
non-empty release-note line so operators can see what changed before updating:

```bash
GITHUB_AGENT_BRIDGE_RELEASE_REPO=pilipilisbot/github-agent-bridge \
gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 monitor --json
```

If GitHub release lookup fails, the monitor records `latest_release_error` in
JSON output but does not fail the health check only because the network or
GitHub API is temporarily unavailable.

### Inspect feedback learning rules

Trusted actionable GitHub notifications are captured into the bridge database as
Expand Down
Binary file added docs/screenshots/pr-38/release-alert-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion src/github_agent_bridge/actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def trigger_actor_details_from_notification(notification: Notification) -> Trigg
return TriggerActor(login=login, avatar_url=github_avatar_url(login)) if login else None


def trigger_actor_details_for_enqueue(notification: Notification, ctx: GitHubContext, *, gh_bin: str = "gh") -> TriggerActor | None:
return github_actor_details_for_context(ctx, gh_bin=gh_bin) or trigger_actor_details_from_notification(notification)


def trigger_actor_from_notification(notification: Notification) -> str | None:
actor = trigger_actor_details_from_notification(notification)
return actor.login if actor else None
Expand Down Expand Up @@ -86,7 +90,10 @@ def github_actor_details_for_context(ctx: GitHubContext, *, gh_bin: str = "gh")
endpoint = actor_endpoint(ctx)
if endpoint is None:
return None
proc = subprocess.run([gh_bin, "api", endpoint], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
proc = subprocess.run([gh_bin, "api", endpoint], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
except OSError:
return None
if proc.returncode != 0:
return None
try:
Expand Down
79 changes: 79 additions & 0 deletions src/github_agent_bridge/monitor.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

import json
import os
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from importlib import metadata
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -122,6 +126,59 @@ def inspect_db(path: str | Path) -> dict[str, Any]:
return inspect_db_read_only(path)


def _package_version() -> str:
try:
return metadata.version("github-agent-bridge")
except metadata.PackageNotFoundError:
return "unknown"


def _normalize_version(version: str) -> str:
return version.strip().lstrip("v")


def _latest_github_release(repo: str, timeout_seconds: float = 5.0) -> dict[str, str] | None:
repo = repo.strip().strip("/")
if not repo:
return None
request = urllib.request.Request(
f"https://api.github.com/repos/{repo}/releases/latest",
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "github-agent-bridge-monitor",
},
)
token = os.getenv("GITHUB_TOKEN", "").strip()
if token:
request.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode("utf-8"))
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
tag = str(payload.get("tag_name") or "").strip()
if not tag:
return None
return {
"tag_name": tag,
"name": str(payload.get("name") or tag),
"html_url": str(payload.get("html_url") or ""),
"published_at": str(payload.get("published_at") or ""),
"body": str(payload.get("body") or ""),
}


def _release_summary(release: dict[str, str]) -> str:
body = release.get("body") or ""
for line in body.splitlines():
stripped = line.strip()
if stripped:
return stripped[:180]
return release.get("name") or release.get("tag_name") or ""


def monitor(
db: str | Path,
executor_unit: str = "github-agent-bridge.service",
Expand All @@ -135,6 +192,28 @@ def monitor(
thresholds = thresholds or MonitorThresholds()
metrics = inspect_db(db)
alerts: list[str] = []
package_version = _package_version()
release_repo = os.getenv("GITHUB_AGENT_BRIDGE_RELEASE_REPO", "").strip()
metrics["package_version"] = package_version
if release_repo:
metrics["release_repo"] = release_repo
latest_release = _latest_github_release(release_repo)
if latest_release:
metrics["latest_release"] = {
key: latest_release[key]
for key in ("tag_name", "name", "html_url", "published_at")
if latest_release.get(key)
}
if package_version != "unknown" and _normalize_version(package_version) != _normalize_version(latest_release["tag_name"]):
summary = _release_summary(latest_release)
detail = f": {summary}" if summary else ""
url = f" ({latest_release['html_url']})" if latest_release.get("html_url") else ""
alerts.append(
f"new github-agent-bridge release {latest_release['tag_name']} available; "
f"installed package version is {package_version}{url}{detail}"
)
else:
metrics["latest_release_error"] = f"could not fetch latest release for {release_repo}"

if not metrics.get("db_exists"):
alerts.append(f"database missing: {metrics.get('db_path')}")
Expand Down
4 changes: 2 additions & 2 deletions src/github_agent_bridge/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .policy import Policy
from .session_correlation import session_id_for_job
from . import feedback
from .actors import trigger_actor_details_from_notification
from .actors import trigger_actor_details_for_enqueue

SCHEMA_PACKAGE = "github_agent_bridge.sql"

Expand Down Expand Up @@ -49,7 +49,7 @@ def enqueue(self, n: Notification, policy: Policy) -> tuple[Job | None, str]:
decision = policy.decision(n, ctx, action)
status = {"auto": "done", "ask": "waiting_approval", "deny": "denied"}.get(decision, "pending")
now = utc_now()
trigger_actor = trigger_actor_details_from_notification(n)
trigger_actor = trigger_actor_details_for_enqueue(n, ctx)
metadata = {"received_at": n.received_at}
with self.connect() as con:
con.execute("BEGIN IMMEDIATE")
Expand Down
3 changes: 3 additions & 0 deletions systemd/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ GITHUB_AGENT_BRIDGE_PROGRESS_WARN_SECONDS=600
GITHUB_AGENT_BRIDGE_AUTO_UNLOCK_STALE_SECONDS=900
GITHUB_AGENT_BRIDGE_ALERT_RESEND_SECONDS=900
GITHUB_AGENT_BRIDGE_PROCESS_SAMPLE_RETENTION_SECONDS=86400
# Optional: check the latest published GitHub release so gab monitor alerts when
# the active service venv lags behind the current release.
GITHUB_AGENT_BRIDGE_RELEASE_REPO=pilipilisbot/github-agent-bridge
GITHUB_AGENT_BRIDGE_OPENCLAW_BIN=openclaw
GITHUB_AGENT_BRIDGE_NODE_BIN=
GITHUB_AGENT_BRIDGE_FEEDBACK_MODEL=
Expand Down
2 changes: 2 additions & 0 deletions tests/test_actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def test_actor_endpoint_prefers_exact_trigger_resource():

def test_backfill_trigger_actors_uses_stored_context(tmp_path, monkeypatch):
db = tmp_path / "q.sqlite3"
monkeypatch.setattr("github_agent_bridge.queue.trigger_actor_details_for_enqueue", lambda notification, ctx: None)
q = JobQueue(db)
job, _ = q.enqueue(
Notification(
Expand Down Expand Up @@ -86,6 +87,7 @@ def fake_run(args, check=False, stdout=None, stderr=None, text=False):

def test_backfill_trigger_actors_fills_missing_avatar_without_api(tmp_path, monkeypatch):
db = tmp_path / "q.sqlite3"
monkeypatch.setattr("github_agent_bridge.actors.github_actor_details_for_context", lambda ctx, *, gh_bin="gh": None)
q = JobQueue(db)
job, _ = q.enqueue(
Notification(
Expand Down
6 changes: 6 additions & 0 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
from urllib.parse import parse_qs, urlparse

import pytest
from fastapi.testclient import TestClient

from github_agent_bridge.backend import DashboardConfig, _encode_session, _session_stream_events, _sign, create_app
Expand All @@ -14,6 +15,11 @@
from github_agent_bridge.queue import JobQueue


@pytest.fixture(autouse=True)
def no_context_actor_lookup(monkeypatch):
monkeypatch.setattr("github_agent_bridge.actors.github_actor_details_for_context", lambda ctx, *, gh_bin="gh": None)


def notif(uid=1, mid="<1@github.com>", body="@pilipilisbot https://github.com/gisce/erp/pull/1#issuecomment-10", from_addr="GitHub <notifications@github.com>"):
return Notification(
uid=uid,
Expand Down
64 changes: 63 additions & 1 deletion tests/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


def notif(uid=1, mid="<1@github.com>", body="@pilipilisbot https://github.com/gisce/erp/pull/1#issuecomment-10"):
return Notification(uid=uid, message_id=mid, subject="Re: [gisce/erp] PR", from_addr="GitHub <notifications@github.com>", body=body, auth={"spf": True, "dkim": True, "dmarc": True})
return Notification(uid=uid, message_id=mid, subject="Re: [gisce/erp] PR", from_addr="ecarreras <notifications@github.com>", body=body, auth={"spf": True, "dkim": True, "dmarc": True})


def test_monitor_ok_on_empty_initialized_db(tmp_path):
Expand All @@ -20,6 +20,68 @@ def test_monitor_ok_on_empty_initialized_db(tmp_path):
assert "pending=0" in report.text()


def test_monitor_alerts_when_github_release_is_newer(tmp_path, monkeypatch):
db = tmp_path / "bridge.sqlite3"
JobQueue(db)
monkeypatch.setattr(monitor_module, "_package_version", lambda: "0.18.1")
monkeypatch.setenv("GITHUB_AGENT_BRIDGE_RELEASE_REPO", "pilipilisbot/github-agent-bridge")
monkeypatch.setattr(
monitor_module,
"_latest_github_release",
lambda repo: {
"tag_name": "v0.18.2",
"name": "v0.18.2",
"html_url": "https://github.com/pilipilisbot/github-agent-bridge/releases/tag/v0.18.2",
"published_at": "2026-05-25T10:00:00Z",
"body": "Fixes the install drift warning.",
},
)

report = monitor(db, check_systemd=False)

assert report.ok is False
assert report.metrics["package_version"] == "0.18.1"
assert report.metrics["release_repo"] == "pilipilisbot/github-agent-bridge"
assert report.metrics["latest_release"]["tag_name"] == "v0.18.2"
assert any("new github-agent-bridge release v0.18.2 available" in a for a in report.alerts)
assert any("Fixes the install drift warning." in a for a in report.alerts)


def test_monitor_does_not_alert_when_github_release_matches(tmp_path, monkeypatch):
db = tmp_path / "bridge.sqlite3"
JobQueue(db)
monkeypatch.setattr(monitor_module, "_package_version", lambda: "0.18.2")
monkeypatch.setenv("GITHUB_AGENT_BRIDGE_RELEASE_REPO", "pilipilisbot/github-agent-bridge")
monkeypatch.setattr(
monitor_module,
"_latest_github_release",
lambda repo: {
"tag_name": "v0.18.2",
"name": "v0.18.2",
"html_url": "https://github.com/pilipilisbot/github-agent-bridge/releases/tag/v0.18.2",
"published_at": "2026-05-25T10:00:00Z",
"body": "Current release.",
},
)

report = monitor(db, check_systemd=False)

assert report.ok is True
assert report.metrics["latest_release"]["tag_name"] == "v0.18.2"


def test_monitor_release_lookup_failure_is_not_alert(tmp_path, monkeypatch):
db = tmp_path / "bridge.sqlite3"
JobQueue(db)
monkeypatch.setenv("GITHUB_AGENT_BRIDGE_RELEASE_REPO", "pilipilisbot/github-agent-bridge")
monkeypatch.setattr(monitor_module, "_latest_github_release", lambda repo: None)

report = monitor(db, check_systemd=False)

assert report.ok is True
assert report.metrics["latest_release_error"] == "could not fetch latest release for pilipilisbot/github-agent-bridge"


def test_monitor_alerts_on_blocked_job(tmp_path):
db = tmp_path / "bridge.sqlite3"
q = JobQueue(db)
Expand Down
Loading
Loading