Files
hans-tools/gh-monitor/buildspec.md

5.5 KiB

gh-monitor — Build Spec

Status: Pending Andrew's review
Depends on: design.md approved


Directory Layout

gh-monitor/
├── design.md
├── buildspec.md
├── poll.py               # Main entry point
├── config/
│   └── watched.yaml      # Repos and filter rules
├── state/
│   ├── last_seen.json    # Event cursor (gitignored)
│   └── errors.log        # Error log (gitignored)
├── requirements.txt
└── .gitignore

Build Order

STEP 1 — .gitignore + requirements.txt

.gitignore:

state/last_seen.json
state/errors.log
__pycache__/
*.pyc
.env

requirements.txt:

PyYAML>=6.0

(All other deps: stdlib + gh CLI)

STEP 2 — config/watched.yaml

Starter config watching the-agency repo:

repos:
  - owner: coding-with-hans-heinemann
    repo: the-agency
    notify_on:
      - review_submitted
      - review_comment
      - issue_comment
      - pr_closed

STEP 3 — poll.py: config + state loader

Functions:

  • load_config(path) -> dict
    Reads watched.yaml. Raises on missing file.

  • load_state(path) -> dict
    Reads last_seen.json. Returns {} if file doesn't exist (first run).

  • save_state(state, path)
    Atomically writes last_seen.json (write to .tmp, rename).

STEP 4 — poll.py: GitHub API client

Function:

  • gh_api(endpoint) -> list | dict
    Runs gh api --paginate <endpoint> as subprocess.
    Returns parsed JSON. Raises GHAPIError on non-zero exit.

  • get_open_prs(owner, repo) -> list[dict]
    Calls /repos/{owner}/{repo}/pulls?state=open.
    Returns list of PR dicts (number, title, html_url).

STEP 5 — poll.py: event fetchers

Functions (each returns list of event dicts with event_type, created_at, actor, body, url):

  • get_reviews(owner, repo, pr_number) -> list[dict]
    /repos/{owner}/{repo}/pulls/{pr_number}/reviews

  • get_review_comments(owner, repo, pr_number) -> list[dict]
    /repos/{owner}/{repo}/pulls/{pr_number}/comments

  • get_issue_comments(owner, repo, pr_number) -> list[dict]
    /repos/{owner}/{repo}/issues/{pr_number}/comments

STEP 6 — poll.py: event diffing

Function:

  • new_events_since(events, cursor_ts) -> list[dict]
    Filters events to those with created_at > cursor_ts.
    Returns sorted by created_at ascending.

STEP 7 — poll.py: notification sender

Function:

  • notify(text)
    Runs openclaw system event --text "<text>" --mode now as subprocess.
    Logs warning and continues on non-zero exit (best-effort).

  • format_notification(repo_slug, pr, event) -> str
    Builds the notification string:
    [gh-monitor] PR #N "title" — <actor> <action>:\n"<body[:200]>"\n<url>

STEP 8 — poll.py: error tracking

Module-level logic:

  • log_error(repo_slug, error, state)
    Appends to state/errors.log.
    Increments state["<repo_slug>"]["consecutive_errors"] counter.
    If counter >= 3 and not already alerted: fires one notify() alert.
    Resets counter to 0 on successful poll for that repo.

STEP 9 — poll.py: main poll loop

Function:

  • poll_repo(repo_cfg, state) -> dict

    1. Get cursor from state (or now if first run).
    2. Fetch open PRs.
    3. For each PR: fetch reviews, review_comments, issue_comments.
    4. Filter to new events since cursor.
    5. Fire notify() for each new event.
    6. Update cursor to max(created_at) of processed events (or now if none).
    7. Return updated state slice.
  • main()
    Loads config + state.
    Calls poll_repo() for each repo in watched.yaml.
    Saves state.
    Exits 0.

Entry point: if __name__ == "__main__": main()

STEP 10 — macOS LaunchAgent

Create ~/Library/LaunchAgents/com.hans.gh-monitor.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.hans.gh-monitor</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>/Users/hansheinemann/Projects/hans-tools/gh-monitor/poll.py</string>
    </array>
    <key>StartInterval</key>
    <integer>300</integer>
    <key>StandardOutPath</key>
    <string>/Users/hansheinemann/Projects/hans-tools/gh-monitor/state/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/hansheinemann/Projects/hans-tools/gh-monitor/state/stderr.log</string>
    <key>RunAtLoad</key>
    <false/>
</dict>
</plist>

Load with:

launchctl load ~/Library/LaunchAgents/com.hans.gh-monitor.plist

Pause/resume:

launchctl unload ~/Library/LaunchAgents/com.hans.gh-monitor.plist
launchctl load   ~/Library/LaunchAgents/com.hans.gh-monitor.plist

Token cost: Zero during polling. The main session is only woken when poll.py calls openclaw system event — i.e. only when real PR activity is detected.


Testing Plan

Manual test steps (no automated tests for v1):

  1. python3 poll.py with no state file → creates state, no notifications (first-run cursor set to now)
  2. Post a comment on PR #1 → run poll.py → notification fires
  3. Run poll.py again immediately → no duplicate notification (cursor advanced)
  4. Break gh binary path temporarily → error logged, no crash
  5. After 3 failed cycles → single alert fires

What Is NOT in This Build

  • Automated test suite
  • Filtering by comment author
  • Digest/batching mode
  • Any write operations to GitHub
  • Anything touching main branch