feat(gh-monitor): implement poll.py — steps 1-9 (#2)
* feat(gh-monitor): implement poll.py — steps 1-9 complete * chore: nudge PR head
This commit is contained in:
5
tools/gh-monitor/.gitignore
vendored
Normal file
5
tools/gh-monitor/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
state/last_seen.json
|
||||||
|
state/errors.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
8
tools/gh-monitor/config/watched.yaml
Normal file
8
tools/gh-monitor/config/watched.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
repos:
|
||||||
|
- owner: coding-with-hans-heinemann
|
||||||
|
repo: the-agency
|
||||||
|
notify_on:
|
||||||
|
- review_submitted
|
||||||
|
- review_comment
|
||||||
|
- issue_comment
|
||||||
|
- pr_closed
|
||||||
282
tools/gh-monitor/poll.py
Normal file
282
tools/gh-monitor/poll.py
Normal file
@@ -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()
|
||||||
1
tools/gh-monitor/requirements.txt
Normal file
1
tools/gh-monitor/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PyYAML>=6.0
|
||||||
2
tools/gh-monitor/state/.gitignore
vendored
Normal file
2
tools/gh-monitor/state/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
last_seen.json
|
||||||
|
errors.log
|
||||||
Reference in New Issue
Block a user