From 43ab5dcc3a17571c192ac7c7ad56f02641bdd438 Mon Sep 17 00:00:00 2001 From: hansheinemann Date: Sun, 15 Mar 2026 18:48:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(gh-monitor):=20implement=20poll.py=20?= =?UTF-8?q?=E2=80=94=20steps=201-9=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gh-monitor): implement poll.py — steps 1-9 complete * chore: nudge PR head --- tools/gh-monitor/.gitignore | 5 + tools/gh-monitor/config/watched.yaml | 8 + tools/gh-monitor/poll.py | 282 +++++++++++++++++++++++++++ tools/gh-monitor/requirements.txt | 1 + tools/gh-monitor/state/.gitignore | 2 + 5 files changed, 298 insertions(+) create mode 100644 tools/gh-monitor/.gitignore create mode 100644 tools/gh-monitor/config/watched.yaml create mode 100644 tools/gh-monitor/poll.py create mode 100644 tools/gh-monitor/requirements.txt create mode 100644 tools/gh-monitor/state/.gitignore diff --git a/tools/gh-monitor/.gitignore b/tools/gh-monitor/.gitignore new file mode 100644 index 0000000..76f4174 --- /dev/null +++ b/tools/gh-monitor/.gitignore @@ -0,0 +1,5 @@ +state/last_seen.json +state/errors.log +__pycache__/ +*.pyc +.env diff --git a/tools/gh-monitor/config/watched.yaml b/tools/gh-monitor/config/watched.yaml new file mode 100644 index 0000000..4cacafc --- /dev/null +++ b/tools/gh-monitor/config/watched.yaml @@ -0,0 +1,8 @@ +repos: + - owner: coding-with-hans-heinemann + repo: the-agency + notify_on: + - review_submitted + - review_comment + - issue_comment + - pr_closed diff --git a/tools/gh-monitor/poll.py b/tools/gh-monitor/poll.py new file mode 100644 index 0000000..8f2bd80 --- /dev/null +++ b/tools/gh-monitor/poll.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""gh-monitor: polls GitHub PRs and fires openclaw notifications on new activity.""" + +import json +import logging +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +BASE_DIR = Path(__file__).parent +CONFIG_PATH = BASE_DIR / "config" / "watched.yaml" +STATE_PATH = BASE_DIR / "state" / "last_seen.json" +ERRORS_LOG = BASE_DIR / "state" / "errors.log" + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("gh-monitor") + +# --------------------------------------------------------------------------- +# Custom exception +# --------------------------------------------------------------------------- + +class GHAPIError(Exception): + pass + + +# --------------------------------------------------------------------------- +# STEP 3 — config + state loader +# --------------------------------------------------------------------------- + +def load_config(path: Path) -> dict: + if not path.exists(): + raise FileNotFoundError(f"Config not found: {path}") + with open(path) as f: + return yaml.safe_load(f) + + +def load_state(path: Path) -> dict: + if not path.exists(): + return {} + with open(path) as f: + return json.load(f) + + +def save_state(state: dict, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + tmp.rename(path) + + +# --------------------------------------------------------------------------- +# STEP 4 — GitHub API client +# --------------------------------------------------------------------------- + +def gh_api(endpoint: str) -> list | dict: + result = subprocess.run( + ["gh", "api", "--paginate", endpoint], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise GHAPIError(f"gh api failed ({result.returncode}): {result.stderr.strip()}") + return json.loads(result.stdout) + + +def get_open_prs(owner: str, repo: str) -> list[dict]: + data = gh_api(f"/repos/{owner}/{repo}/pulls?state=open") + return [{"number": pr["number"], "title": pr["title"], "html_url": pr["html_url"]} for pr in data] + + +# --------------------------------------------------------------------------- +# STEP 5 — event fetchers +# --------------------------------------------------------------------------- + +def _normalize(event_type: str, items: list[dict]) -> list[dict]: + """Normalize raw GitHub API items into a common event dict shape.""" + events = [] + for item in items: + actor = (item.get("user") or item.get("author") or {}).get("login", "unknown") + body = item.get("body") or "" + url = item.get("html_url") or "" + created_at = item.get("submitted_at") or item.get("created_at") or "" + # review state maps to a human-readable action + action = item.get("state", "").lower() if event_type == "review_submitted" else event_type + events.append({ + "event_type": event_type, + "action": action or event_type, + "created_at": created_at, + "actor": actor, + "body": body, + "url": url, + }) + return events + + +def get_reviews(owner: str, repo: str, pr_number: int) -> list[dict]: + items = gh_api(f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews") + return _normalize("review_submitted", items) + + +def get_review_comments(owner: str, repo: str, pr_number: int) -> list[dict]: + items = gh_api(f"/repos/{owner}/{repo}/pulls/{pr_number}/comments") + return _normalize("review_comment", items) + + +def get_issue_comments(owner: str, repo: str, pr_number: int) -> list[dict]: + items = gh_api(f"/repos/{owner}/{repo}/issues/{pr_number}/comments") + return _normalize("issue_comment", items) + + +# --------------------------------------------------------------------------- +# STEP 6 — event diffing +# --------------------------------------------------------------------------- + +def new_events_since(events: list[dict], cursor_ts: str) -> list[dict]: + filtered = [e for e in events if e["created_at"] > cursor_ts] + return sorted(filtered, key=lambda e: e["created_at"]) + + +# --------------------------------------------------------------------------- +# STEP 7 — notification sender +# --------------------------------------------------------------------------- + +def notify(text: str) -> None: + result = subprocess.run( + ["openclaw", "system", "event", "--text", text, "--mode", "now"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + log.warning("notify failed (%d): %s", result.returncode, result.stderr.strip()) + + +def format_notification(repo_slug: str, pr: dict, event: dict) -> str: + number = pr["number"] + title = pr["title"] + actor = event["actor"] + action = event["action"] + body_preview = (event["body"] or "")[:200] + url = event["url"] + return f'[gh-monitor] PR #{number} "{title}" — {actor} {action}:\n"{body_preview}"\n{url}' + + +# --------------------------------------------------------------------------- +# STEP 8 — error tracking +# --------------------------------------------------------------------------- + +def log_error(repo_slug: str, error: Exception, state: dict) -> None: + # Append to errors.log + ERRORS_LOG.parent.mkdir(parents=True, exist_ok=True) + with open(ERRORS_LOG, "a") as f: + ts = datetime.now(timezone.utc).isoformat() + f.write(f"{ts} [{repo_slug}] {error}\n") + + repo_state = state.setdefault(repo_slug, {}) + repo_state["consecutive_errors"] = repo_state.get("consecutive_errors", 0) + 1 + + if repo_state["consecutive_errors"] >= 3 and not repo_state.get("error_alerted"): + notify(f"[gh-monitor] {repo_slug} has failed {repo_state['consecutive_errors']} times in a row — check errors.log") + repo_state["error_alerted"] = True + + +def reset_errors(repo_slug: str, state: dict) -> None: + repo_state = state.setdefault(repo_slug, {}) + repo_state["consecutive_errors"] = 0 + repo_state["error_alerted"] = False + + +# --------------------------------------------------------------------------- +# STEP 9 — main poll loop +# --------------------------------------------------------------------------- + +def poll_repo(repo_cfg: dict, state: dict) -> dict: + owner = repo_cfg["owner"] + repo = repo_cfg["repo"] + notify_on = set(repo_cfg.get("notify_on", [])) + repo_slug = f"{owner}/{repo}" + + repo_state = state.setdefault(repo_slug, {}) + now_ts = datetime.now(timezone.utc).isoformat() + cursor = repo_state.get("cursor", now_ts) + + # First run: cursor = now (no backfill) + if "cursor" not in repo_state: + repo_state["cursor"] = now_ts + log.info("[%s] First run — cursor set to now, no backfill.", repo_slug) + return state + + try: + open_prs = get_open_prs(owner, repo) + except GHAPIError as e: + log.error("[%s] Failed to fetch PRs: %s", repo_slug, e) + log_error(repo_slug, e, state) + return state + + all_new_events: list[tuple[dict, dict]] = [] # (pr, event) + + for pr in open_prs: + pr_number = pr["number"] + fetchers = [] + if "review_submitted" in notify_on: + fetchers.append(get_reviews) + if "review_comment" in notify_on: + fetchers.append(get_review_comments) + if "issue_comment" in notify_on: + fetchers.append(get_issue_comments) + + for fetcher in fetchers: + try: + events = fetcher(owner, repo, pr_number) + new = new_events_since(events, cursor) + for event in new: + all_new_events.append((pr, event)) + except GHAPIError as e: + log.error("[%s] PR #%d fetch error: %s", repo_slug, pr_number, e) + log_error(repo_slug, e, state) + return state + + # Also check pr_closed if configured (PRs that closed since cursor) + if "pr_closed" in notify_on: + try: + closed_data = gh_api(f"/repos/{owner}/{repo}/pulls?state=closed") + for pr_raw in closed_data: + closed_at = pr_raw.get("closed_at") or "" + if closed_at > cursor: + pr = {"number": pr_raw["number"], "title": pr_raw["title"], "html_url": pr_raw["html_url"]} + actor = (pr_raw.get("user") or {}).get("login", "unknown") + merged = pr_raw.get("merged_at") is not None + action = "merged" if merged else "closed" + event = { + "event_type": "pr_closed", + "action": action, + "created_at": closed_at, + "actor": actor, + "body": pr_raw.get("body") or "", + "url": pr_raw["html_url"], + } + all_new_events.append((pr, event)) + except GHAPIError as e: + log.error("[%s] Failed to fetch closed PRs: %s", repo_slug, e) + log_error(repo_slug, e, state) + return state + + # Sort all new events by created_at ascending + all_new_events.sort(key=lambda x: x[1]["created_at"]) + + for pr, event in all_new_events: + text = format_notification(repo_slug, pr, event) + notify(text) + log.info("[%s] Notified: PR #%d %s by %s", repo_slug, pr["number"], event["action"], event["actor"]) + + # Update cursor + if all_new_events: + repo_state["cursor"] = max(e["created_at"] for _, e in all_new_events) + else: + repo_state["cursor"] = now_ts + + reset_errors(repo_slug, state) + return state + + +def main() -> None: + config = load_config(CONFIG_PATH) + state = load_state(STATE_PATH) + + for repo_cfg in config.get("repos", []): + state = poll_repo(repo_cfg, state) + + save_state(state, STATE_PATH) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tools/gh-monitor/requirements.txt b/tools/gh-monitor/requirements.txt new file mode 100644 index 0000000..c1a201d --- /dev/null +++ b/tools/gh-monitor/requirements.txt @@ -0,0 +1 @@ +PyYAML>=6.0 diff --git a/tools/gh-monitor/state/.gitignore b/tools/gh-monitor/state/.gitignore new file mode 100644 index 0000000..7ae1c4e --- /dev/null +++ b/tools/gh-monitor/state/.gitignore @@ -0,0 +1,2 @@ +last_seen.json +errors.log