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
22 changes: 12 additions & 10 deletions src/github_agent_bridge/autoupdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,8 @@
"src/github_agent_bridge/dashboard_data.py",
"src/github_agent_bridge/dashboard_static/",
)
WEBHOOK_PATH_PREFIXES = (
"src/github_agent_bridge/backend.py",
"src/github_agent_bridge/models.py",
"src/github_agent_bridge/parser.py",
"src/github_agent_bridge/policy.py",
"src/github_agent_bridge/queue.py",
"src/github_agent_bridge/sql/",
"src/github_agent_bridge/webhook.py",
)
WEBHOOK_PATH_PREFIXES = ("src/github_agent_bridge/",)
WEBHOOK_PATH_EXCLUSIONS = ("src/github_agent_bridge/dashboard_static/",)
SYSTEMD_PATH_PREFIXES = ("systemd/",)
DEFAULT_SYSTEMD_UNITS = {
"executor": "github-agent-bridge.service",
Expand Down Expand Up @@ -147,7 +140,16 @@ def classify_changed_files(files: Sequence[str]) -> dict[str, Any]:
risky_files = [path for path in files if path.startswith(RISKY_PATH_PREFIXES)]
migration_files = [path for path in files if path.startswith("src/github_agent_bridge/sql/") or "/migrations/" in path]
dashboard_files = [path for path in files if path.startswith(DASHBOARD_PATH_PREFIXES)]
webhook_files = [path for path in files if path.startswith(WEBHOOK_PATH_PREFIXES)]
# The webhook entry point imports backend.py, which in turn imports most of
# the package. Treat package code as shared runtime code so an update cannot
# leave ingress running an older import graph. Dashboard static assets are
# the only package subtree that is not loaded by the webhook process.
webhook_files = [
path
for path in files
if path.startswith(WEBHOOK_PATH_PREFIXES)
and not path.startswith(WEBHOOK_PATH_EXCLUSIONS)
]
systemd_files = [path for path in files if path.startswith(SYSTEMD_PATH_PREFIXES)]
dashboard_only = bool(files) and len(dashboard_files) == len(files) and not webhook_files
webhook_only = bool(files) and len(webhook_files) == len(files) and not dashboard_files and not risky_files
Expand Down
7 changes: 6 additions & 1 deletion src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,11 @@ def create_webhook_app(config: DashboardConfig | None = None) -> FastAPI:

@app.get("/api/health")
def health() -> dict[str, Any]:
return {"ok": True, "service": "github-agent-bridge-webhook-ingress"}
return {
"ok": True,
"service": "github-agent-bridge-webhook-ingress",
"version": __version__,
}

@app.post("/api/webhooks/github")
async def github_webhook(request: Request) -> dict[str, Any]:
Expand Down Expand Up @@ -986,6 +990,7 @@ def health() -> dict[str, Any]:
return {
"ok": bool(metrics.get("db_exists") and metrics.get("schema_ok", True)),
"service": "github-agent-bridge-dashboard",
"version": __version__,
"db_exists": bool(metrics.get("db_exists")),
"schema_ok": bool(metrics.get("schema_ok", True)),
"oauth_configured": config.oauth_ready,
Expand Down
17 changes: 14 additions & 3 deletions src/github_agent_bridge/monitor_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -14,6 +16,12 @@
from .observability import configure_sentry


def _default_bridge_bin() -> str:
"""Resolve the bridge CLI installed beside this monitor entry point."""
monitor_bin = shutil.which(sys.argv[0]) or sys.argv[0]
return str(Path(monitor_bin).resolve().with_name("github-agent-bridge"))


@dataclass(frozen=True)
class AlertConfig:
bridge_bin: str
Expand Down Expand Up @@ -56,8 +64,11 @@ def proc_state_file(self) -> Path:
def from_env(cls, env: dict[str, str] | None = None) -> AlertConfig:
values = env or os.environ
state_dir = Path(values.get("GITHUB_AGENT_BRIDGE_STATE_DIR", "~/.local/state/github-agent-bridge")).expanduser()
bridge_bin = values.get("GITHUB_AGENT_BRIDGE_BIN")
if bridge_bin is None:
bridge_bin = _default_bridge_bin()
return cls(
bridge_bin=values.get("GITHUB_AGENT_BRIDGE_BIN", "~/.local/bin/github-agent-bridge"),
bridge_bin=bridge_bin,
openclaw_bin=values.get("GITHUB_AGENT_BRIDGE_OPENCLAW_BIN", "~/.nvm/versions/node/v24.14.0/bin/openclaw"),
db=values.get("GITHUB_AGENT_BRIDGE_DB", "~/.local/state/github-agent-bridge/bridge.sqlite3"),
policy=values.get("GITHUB_AGENT_BRIDGE_POLICY", "~/.config/github-agent-bridge/policy.json"),
Expand Down Expand Up @@ -517,10 +528,10 @@ def main(argv: list[str] | None = None) -> int:
print(unlock_output, end="" if unlock_output.endswith("\n") else "\n")

if not should_send_alert(config.state_file, output, config.resend_seconds):
return 0
return monitor_proc.returncode

send_alert(config, output, unlock_output)
return 0
return monitor_proc.returncode


if __name__ == "__main__":
Expand Down
20 changes: 20 additions & 0 deletions tests/test_autoupdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ def test_persistence_runtime_changes_require_executor_reload():
]


@pytest.mark.parametrize(
"changed_file",
[
"src/github_agent_bridge/actionability.py",
"src/github_agent_bridge/persistence/webhooks.py",
],
)
def test_shared_runtime_changes_restart_webhook_ingress(changed_file):
classification = classify_changed_files([changed_file])
plan = plan_systemd_actions("stage_full_reload", classification)

assert classification["webhook_files"] == [changed_file]
assert [item["unit"] for item in plan["immediate"]] == [
"github-agent-bridge-dashboard.service",
"github-agent-bridge-webhook.service",
"github-agent-bridge.service",
]


def test_latest_release_reports_missing_gh_as_runtime_error():
with pytest.raises(RuntimeError, match=r"No such file or directory.*missing-gh"):
latest_release("gisce/github-agent-bridge", gh_bin="/missing-gh")
Expand Down Expand Up @@ -421,6 +440,7 @@ def test_full_update_is_allowed_when_queue_is_quiet(tmp_path, monkeypatch):
assert plan["executor_restart_allowed"] is True
assert [item["unit"] for item in plan["service_plan"]["immediate"]] == [
"github-agent-bridge-dashboard.service",
"github-agent-bridge-webhook.service",
"github-agent-bridge.service",
]

Expand Down
1 change: 1 addition & 0 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ def test_dashboard_missing_db_does_not_create_database(tmp_path):
response = TestClient(app).get("/api/health")

assert response.json()["db_exists"] is False
assert response.json()["version"] == __version__
assert db.exists() is False


Expand Down
76 changes: 76 additions & 0 deletions tests/test_monitor_alert.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

from github_agent_bridge import monitor_alert
Expand Down Expand Up @@ -34,6 +36,80 @@ def test_should_send_alert_persists_and_throttles(tmp_path):
assert monitor_alert.should_send_alert(state_file, "boom", resend_seconds=900, now=2001) is True


def test_alert_config_defaults_to_bridge_beside_user_entrypoint(tmp_path, monkeypatch):
monitor_bin = tmp_path / ".local/bin/github-agent-bridge-monitor-alert"
monkeypatch.setattr(sys, "argv", ["github-agent-bridge-monitor-alert"])
monkeypatch.setattr(monitor_alert.shutil, "which", lambda executable: str(monitor_bin))

config = monitor_alert.AlertConfig.from_env({
"GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path),
})

assert config.bridge_bin == str(monitor_bin.with_name("github-agent-bridge"))


def test_alert_config_defaults_to_bridge_beside_virtualenv_entrypoint(tmp_path, monkeypatch):
monitor_bin = tmp_path / ".venv/bin/github-agent-bridge-monitor-alert"
monkeypatch.setattr(sys, "argv", [str(monitor_bin)])
monkeypatch.setattr(monitor_alert.shutil, "which", lambda executable: None)

config = monitor_alert.AlertConfig.from_env({
"GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path),
})

assert config.bridge_bin == str(monitor_bin.with_name("github-agent-bridge"))


def test_alert_config_explicit_bridge_bin_overrides_entrypoint_resolution(tmp_path, monkeypatch):
def fail_default():
raise AssertionError("default resolution must not run for an explicit override")

monkeypatch.setattr(monitor_alert, "_default_bridge_bin", fail_default)

config = monitor_alert.AlertConfig.from_env({
"GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path),
"GITHUB_AGENT_BRIDGE_BIN": "/opt/github-agent-bridge",
})

assert config.bridge_bin == "/opt/github-agent-bridge"


def test_main_propagates_monitor_failure_after_sending_alert(tmp_path, monkeypatch):
config = make_config(tmp_path)
sent = []
monkeypatch.setattr(monitor_alert.AlertConfig, "from_env", lambda: config)
monkeypatch.setattr(
monitor_alert,
"run_monitor",
lambda config: subprocess.CompletedProcess([], 2, "invalid monitor arguments\n"),
)
monkeypatch.setattr(monitor_alert, "maybe_unlock_stale", lambda config, output: "")
monkeypatch.setattr(monitor_alert, "should_send_alert", lambda *args, **kwargs: True)
monkeypatch.setattr(monitor_alert, "send_alert", lambda config, output, unlock: sent.append(output))

assert monitor_alert.main() == 2
assert sent == ["invalid monitor arguments\n"]


def test_main_propagates_throttled_monitor_failure(tmp_path, monkeypatch):
config = make_config(tmp_path)
monkeypatch.setattr(monitor_alert.AlertConfig, "from_env", lambda: config)
monkeypatch.setattr(
monitor_alert,
"run_monitor",
lambda config: subprocess.CompletedProcess([], 3, "still failing\n"),
)
monkeypatch.setattr(monitor_alert, "maybe_unlock_stale", lambda config, output: "")
monkeypatch.setattr(monitor_alert, "should_send_alert", lambda *args, **kwargs: False)
monkeypatch.setattr(
monitor_alert,
"send_alert",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("alert is throttled")),
)

assert monitor_alert.main() == 3


def test_alert_config_passes_slo_thresholds_from_environment(tmp_path, monkeypatch):
config = monitor_alert.AlertConfig.from_env({
"GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path),
Expand Down
1 change: 1 addition & 0 deletions tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def test_dedicated_webhook_ingress_exposes_only_health_and_delivery(tmp_path):

assert client.get("/api/health").json() == {
"ok": True, "service": "github-agent-bridge-webhook-ingress",
"version": backend.__version__,
}
assert client.post(
"/api/webhooks/github", content=payload, headers=signed_headers(payload),
Expand Down
Loading