From 9f23deb309f532c599f1f0e63101b7c4ab87049d Mon Sep 17 00:00:00 2001 From: Hans Heinemann Date: Sun, 15 Mar 2026 17:12:24 -0400 Subject: [PATCH 1/3] feat(gh-monitor): design doc and build spec for GitHub PR polling monitor --- README.md | 26 ++++++ gh-monitor/buildspec.md | 182 ++++++++++++++++++++++++++++++++++++++++ gh-monitor/design.md | 158 ++++++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 README.md create mode 100644 gh-monitor/buildspec.md create mode 100644 gh-monitor/design.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..599bb98 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# hans-tools + +Personal tooling for Hans — GitHub PR monitoring, automation hooks, and utilities. + +## Tools + +| Tool | Description | Status | +|---|---|---| +| `gh-monitor` | Polls GitHub for PR activity and notifies Hans via OpenClaw | Spec review | + +## Structure + +``` +hans-tools/ +├── gh-monitor/ # GitHub PR polling monitor +│ ├── design.md # Design doc +│ ├── buildspec.md # Build spec +│ └── ... # Implementation (pending review) +└── README.md +``` + +## Principles + +- Every tool has a design doc and build spec reviewed before implementation +- No secrets in the repo — all credentials via environment variables +- Tools run as cron jobs or daemons managed by OpenClaw diff --git a/gh-monitor/buildspec.md b/gh-monitor/buildspec.md new file mode 100644 index 0000000..57bf4c8 --- /dev/null +++ b/gh-monitor/buildspec.md @@ -0,0 +1,182 @@ +# 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 — OpenClaw cron job + +Register via OpenClaw cron API: +```json +{ + "name": "gh-monitor", + "schedule": { "kind": "every", "everyMs": 300000 }, + "payload": { + "kind": "systemEvent", + "text": "Run GitHub PR monitor: cd ~/Projects/hans-tools/gh-monitor && python3 poll.py" + }, + "sessionTarget": "main" +} +``` + +Note: this is a systemEvent (not agentTurn) so it injects into the main session +and Hans handles it inline. If this proves noisy, switch to agentTurn in isolated +session. + +--- + +## 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 diff --git a/gh-monitor/design.md b/gh-monitor/design.md new file mode 100644 index 0000000..0807273 --- /dev/null +++ b/gh-monitor/design.md @@ -0,0 +1,158 @@ +# gh-monitor — Design Doc + +**Status:** Pending Andrew's review +**Repo:** coding-with-hans-heinemann/hans-tools +**Author:** Hans Heinemann + +--- + +## What It Does + +Polls the GitHub API for activity on watched repositories and fires OpenClaw +system events to wake Hans when action is needed. Hans can then read the PR, +respond to comments, push fixes, or request changes — all without a public +webhook endpoint. + +--- + +## Scope + +Initial scope: PRs only. Issues, CI, and deployments out of scope for v1. + +Events monitored: +- New review submitted (approved, changes requested, commented) +- New PR review comment posted +- New PR issue comment posted +- PR merged or closed + +Events NOT monitored in v1: +- CI/check status +- Issue activity +- Dependabot alerts + +--- + +## Architecture + +``` +cron (every 5 min) + └── gh-monitor/poll.py + ├── reads config/watched.yaml (repos + filter rules) + ├── reads state/last_seen.json (per-repo event cursor) + ├── calls GitHub API via gh CLI (no extra credentials) + ├── diffs against last_seen + ├── for each new event: + │ └── fires openclaw system event (text summary) + └── writes updated last_seen.json +``` + +Hans receives OpenClaw system event → session wakes → Hans reads + acts. + +--- + +## Config — watched.yaml + +```yaml +repos: + - owner: coding-with-hans-heinemann + repo: the-agency + notify_on: + - review_submitted + - review_comment + - issue_comment + - pr_closed +``` + +Multiple repos supported. Per-repo filter rules. + +--- + +## State — last_seen.json + +Tracks the timestamp of the last processed event per repo. On each poll, +only events newer than this cursor are processed. Prevents duplicate alerts. + +```json +{ + "coding-with-hans-heinemann/the-agency": { + "last_event_at": "2026-03-15T17:00:00Z" + } +} +``` + +On first run (no state file), cursor is set to now — no backfill of old events. + +--- + +## Notification Format + +OpenClaw system event text: + +``` +[gh-monitor] PR #1 "feat: Phase 2" — Andrew left a review comment: +"The escalation retry logic looks good but can you add a test for the blocked case?" +https://github.com/coding-with-hans-heinemann/the-agency/pull/1#discussion_r12345 +``` + +One event per notification. If multiple events arrive in one poll cycle, they +fire as separate system events in sequence. + +--- + +## GitHub API Access + +Uses `gh` CLI (already installed, already authenticated as hansheinemann). +No new credentials needed. All API calls go through `gh api`. + +Endpoints used: +- `GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews` +- `GET /repos/{owner}/{repo}/pulls/{pull_number}/comments` +- `GET /repos/{owner}/{repo}/issues/{pull_number}/comments` +- `GET /repos/{owner}/{repo}/pulls` (list open PRs) + +Rate limit: 5,000 requests/hour for authenticated requests. At 5-min poll +intervals across a handful of repos, this is nowhere near the limit. + +--- + +## Cron Schedule + +Every 5 minutes via OpenClaw cron: +``` +{ "kind": "every", "everyMs": 300000 } +``` + +Payload: systemEvent → injects wake text into main session. + +Can be paused/resumed via OpenClaw cron management without touching the code. + +--- + +## Error Handling + +- GitHub API errors: log to `state/errors.log`, skip that repo for this cycle +- Malformed API response: log and skip +- Missing state file: create fresh with cursor = now +- `gh` CLI not found: exit with error message + +Errors do NOT fire system events (avoid alert fatigue from transient API blips). +If errors persist for >3 consecutive cycles, fire one alert to Hans. + +--- + +## Security + +- No webhook endpoint — nothing exposed to the internet +- No secrets stored in the repo — `gh` CLI handles auth via its own keychain +- State files excluded from git via .gitignore +- Read-only GitHub API access needed (no write scopes required for polling) + +--- + +## Out of Scope (v1) + +- Filtering by PR author +- Filtering by comment author +- Digest mode (batch multiple events into one notification) +- Slack/email delivery (OpenClaw system event only) +- CI/check status monitoring From 82f0cdb41318a1cd9039012f3898c8f42a262257 Mon Sep 17 00:00:00 2001 From: Hans Heinemann Date: Sun, 15 Mar 2026 17:30:10 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(gh-monitor):=20replace=20OpenClaw=20cro?= =?UTF-8?q?n=20with=20launchd=20=E2=80=94=20zero=20polling=20token=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gh-monitor/buildspec.md | 55 ++++++++++++++++++++++++++++++----------- gh-monitor/design.md | 14 +++++------ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/gh-monitor/buildspec.md b/gh-monitor/buildspec.md index 57bf4c8..5a6d309 100644 --- a/gh-monitor/buildspec.md +++ b/gh-monitor/buildspec.md @@ -140,24 +140,49 @@ Function: Entry point: `if __name__ == "__main__": main()` -### STEP 10 — OpenClaw cron job +### STEP 10 — macOS LaunchAgent -Register via OpenClaw cron API: -```json -{ - "name": "gh-monitor", - "schedule": { "kind": "every", "everyMs": 300000 }, - "payload": { - "kind": "systemEvent", - "text": "Run GitHub PR monitor: cd ~/Projects/hans-tools/gh-monitor && python3 poll.py" - }, - "sessionTarget": "main" -} +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 + + + ``` -Note: this is a systemEvent (not agentTurn) so it injects into the main session -and Hans handles it inline. If this proves noisy, switch to agentTurn in isolated -session. +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. --- diff --git a/gh-monitor/design.md b/gh-monitor/design.md index 0807273..fbf87d2 100644 --- a/gh-monitor/design.md +++ b/gh-monitor/design.md @@ -115,16 +115,16 @@ intervals across a handful of repos, this is nowhere near the limit. --- -## Cron Schedule +## Schedule -Every 5 minutes via OpenClaw cron: -``` -{ "kind": "every", "everyMs": 300000 } -``` +Every 5 minutes via macOS `launchd` (LaunchAgent plist). `poll.py` runs as a +plain shell process — no agent session, no tokens consumed during polling. -Payload: systemEvent → injects wake text into main session. +The main session is only woken when `poll.py` finds new activity and calls +`openclaw system event`. On a quiet repo this costs zero tokens most of the day. -Can be paused/resumed via OpenClaw cron management without touching the code. +The LaunchAgent can be unloaded/loaded via `launchctl` to pause/resume polling +without touching the code. --- From 789cfc03a3e6540e7b824d1cb1d6f594d5f66d34 Mon Sep 17 00:00:00 2001 From: Hans Heinemann Date: Sun, 15 Mar 2026 17:34:46 -0400 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20reorganize=20=E2=80=94=20tools/=20?= =?UTF-8?q?subfolder,=20README=20as=20central=20index=20with=20linked=20pr?= =?UTF-8?q?ojects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 38 +++++++++++++------ {gh-monitor => tools/gh-monitor}/buildspec.md | 0 {gh-monitor => tools/gh-monitor}/design.md | 0 3 files changed, 26 insertions(+), 12 deletions(-) rename {gh-monitor => tools/gh-monitor}/buildspec.md (100%) rename {gh-monitor => tools/gh-monitor}/design.md (100%) diff --git a/README.md b/README.md index 599bb98..016a293 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,40 @@ # hans-tools -Personal tooling for Hans — GitHub PR monitoring, automation hooks, and utilities. +Central reference for all tools built for Hans — small utilities live here, +larger standalone projects are linked below. -## Tools +--- + +## Small Tools (`tools/`) | Tool | Description | Status | |---|---|---| -| `gh-monitor` | Polls GitHub for PR activity and notifies Hans via OpenClaw | Spec review | +| [gh-monitor](tools/gh-monitor/) | Polls GitHub for PR activity, notifies Hans via OpenClaw | Spec review | + +--- + +## Larger Projects + +| Project | Repo | Description | Status | +|---|---|---|---| +| the-agency | [coding-with-hans-heinemann/the-agency](https://github.com/coding-with-hans-heinemann/the-agency) | Tiered multi-agent orchestration pipeline (T1–T5) | Phase 2 complete | + +--- ## Structure ``` hans-tools/ -├── gh-monitor/ # GitHub PR polling monitor -│ ├── design.md # Design doc -│ ├── buildspec.md # Build spec -│ └── ... # Implementation (pending review) -└── README.md +├── tools/ # Self-contained small tools +│ └── / +│ ├── design.md +│ ├── buildspec.md +│ └── ... +└── README.md # This index ``` -## Principles +## Conventions -- Every tool has a design doc and build spec reviewed before implementation -- No secrets in the repo — all credentials via environment variables -- Tools run as cron jobs or daemons managed by OpenClaw +- Every tool has a `design.md` and `buildspec.md` reviewed before implementation +- No secrets in the repo — credentials via environment variables only +- All work on feature branches (`hans/...`), never directly to `main` diff --git a/gh-monitor/buildspec.md b/tools/gh-monitor/buildspec.md similarity index 100% rename from gh-monitor/buildspec.md rename to tools/gh-monitor/buildspec.md diff --git a/gh-monitor/design.md b/tools/gh-monitor/design.md similarity index 100% rename from gh-monitor/design.md rename to tools/gh-monitor/design.md