# 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: ```yaml 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 ` 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 "" --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" — :\n""\n` ### STEP 8 — poll.py: error tracking Module-level logic: - `log_error(repo_slug, error, state)` Appends to `state/errors.log`. Increments `state[""]["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 Label com.hans.gh-monitor ProgramArguments /usr/bin/python3 /Users/hansheinemann/Projects/hans-tools/gh-monitor/poll.py StartInterval 300 StandardOutPath /Users/hansheinemann/Projects/hans-tools/gh-monitor/state/stdout.log StandardErrorPath /Users/hansheinemann/Projects/hans-tools/gh-monitor/state/stderr.log RunAtLoad ``` Load with: ```bash launchctl load ~/Library/LaunchAgents/com.hans.gh-monitor.plist ``` Pause/resume: ```bash 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