Polls GitHub on a schedule for pull requests waiting on your review and DMs you in Slack. Runs locally. No server, no Python dependencies, no Slack app.
scheduler (default every 50 min)
└─ poller.py
├─ gh auth token ──▶ GitHub: review-requested + team-review-requested
├─ age window, diff against seen.json
└─ if anything is new: claude -p ──▶ Slack MCP ──▶ DM
Requires Python 3.8+, gh logged in, and the claude CLI with Slack's MCP connected. Works on macOS (launchd), Linux (systemd or cron), and Windows (Task Scheduler).
gh auth refresh -h github.com -s read:org # read:org is required, see below
python3 install.py
python3 poller.py --check # confirm; non-zero if anything is wrongThe installer asks for your teams, the age window, and your Slack member ID, then writes the config, sends a test DM, silently adopts your current backlog so day one isn't a flood, and schedules the job.
To script it instead, see Driving this from an agent.
Two searches run per cycle, because review-requested:@me does not match PRs where a team you belong to was asked:
is:open is:pr archived:false review-requested:@me
is:open is:pr archived:false team-review-requested:<org>/<team>
Without read:org the second silently returns nothing and you miss an entire class of request. Relatedly, if GitHub can't resolve a team slug it drops the qualifier rather than erroring, matching every open PR on GitHub — so any result over 200 is refused and --check names the query.
config.json in the config directory (poller.py --check prints the path). Plain JSON; missing keys use defaults. Changes apply on the next poll, except interval_seconds.
| Key | Default | Meaning |
|---|---|---|
teams |
[] |
"org/team" slugs to watch. Omitted teams go unnoticed. |
ignore_drafts |
false |
Skip draft PRs. |
interval_seconds |
3000 |
50 min. Floor 30. Needs install.py --reschedule to take effect. |
max_age_days |
30 |
Only notify about PRs active in N days. 0 = no limit. |
gh_path |
"" |
Absolute path to gh. "" auto-detects. |
claude_path |
"" |
Absolute path to claude. "" auto-detects. |
slack_user_id |
"" |
Your Slack member ID (U…), the DM target. |
Two things that fail silently if you get them wrong:
interval_secondsis baked into the scheduled job at install time. Editing the file alone changes nothing; runinstall.py --reschedule.- Set the absolute paths. Schedulers run with a near-empty
PATHthat excludes Homebrew and~/.local/bin, so auto-detection works by hand and fails once scheduled. The installer writes them for you.
GitHub token resolves as $GRP_GITHUB_TOKEN → gh auth token → secrets.json. Read fresh each run and never cached, so gh rotation needs no reinstall. secrets.json is the fallback if gh's credential store is unreadable from a background job.
Environment overrides: GRP_GITHUB_TOKEN, GRP_CONFIG_DIR, GRP_STATE_DIR, GRP_LOG_DIR. launchd and systemd bake any GRP_* set at install time into the job; cron and Task Scheduler do not.
max_age_days skips requests that have gone quiet, measured on last activity, not open date:
max_age_days: 30 |
|
|---|---|
| Opened 4 months ago, review re-requested today | notifies |
| Opened 10 days ago, untouched for 40 days | skipped |
Stale PRs are marked rather than dropped, so retuning the window never replays old PRs as new. (Why: reviewpoller/github.py.)
python3 install.py --purge # job + every file created; lists them, then asks
python3 install.py --uninstall # job only, keeps configBy hand — run --check first to print the real paths, which move if $XDG_CONFIG_HOME, %APPDATA%, or GRP_*_DIR are set:
# macOS
launchctl bootout "gui/$(id -u)/com.github-review-poller"
rm -f ~/Library/LaunchAgents/com.github-review-poller.plist
rm -rf ~/Library/Application\ Support/github-review-poller ~/Library/Logs/github-review-poller
# Linux (systemd; without it, remove the crontab line tagged # github-review-poller)
systemctl --user disable --now github-review-poller.timer
rm -f ~/.config/systemd/user/github-review-poller.{service,timer}
systemctl --user daemon-reload
rm -rf ~/.config/github-review-poller ~/.local/state/github-review-poller
# Windows
schtasks /delete /tn GitHubReviewPoller /f
rmdir /s /q "%APPDATA%\github-review-poller" "%LOCALAPPDATA%\github-review-poller"Your gh login is never touched — this stores no GitHub token.
gh auth login and connecting the Slack MCP are browser OAuth flows a human does once. Everything else is scriptable:
python3 install.py --non-interactive \
--slack-user-id U0123ABCDEF \
--teams org/a,org/b \
--max-age-days 30 --interval 3000
python3 poller.py --check --json
python3 install.py --reschedule
python3 install.py --purge --yesExactly one of --teams, --all-teams, --no-teams is required — not defaulted, because a missing team silently means never hearing about its requests. --non-interactive never prompts or guesses; anything it can't determine is an error naming the flag that fixes it.
| Exit | Meaning |
|---|---|
0 |
Success |
2 |
Config incomplete or invalid — nothing written |
3 |
Installed, but the test DM could not be confirmed |
--check --json returns the same information as the human report with the same exit codes (0 clean, 1 problems). Keys: ok, failures, warnings, paths, config, binaries, github, slack, state, scheduler. Check ok, read failures. github.token_source says where the credential came from, never its value.
Setting GRP_CONFIG_DIR/GRP_STATE_DIR also scopes the job name, so a second instance coexists with a live one and a scoped --purge can't reach through and remove it.
Bot tokens and incoming webhooks need an installed Slack app; workflow webhooks need Workflow Builder. Where an administrator allows none of those, there's no credential to hold — but an already-connected Slack MCP is itself an authorised path, and its OAuth lives on disk, so a scheduled job can reach it via claude -p.
Python composes the exact message text and hands Claude one tool, so nothing varies run to run. Costs: no ok: true to check (success requires an exact sentinel, biased toward a duplicate DM over a dropped one), 10–30s per cycle that has something to send, and model tokens — quiet cycles invoke nothing.
If you can get a bot token, prefer it — faster, free, real delivery receipt. Notifier.send(text) is the only interface to replace.
python3 poller.py # one poll (what the scheduler runs)
python3 poller.py --check [--json] # diagnose; --post-test sends a real DM
python3 poller.py --dry-run # what would be sent; touches nothing
python3 poller.py --seed [--announce] # adopt the backlog, optionally summarise it
python3 poller.py --watch # poll in the foreground
python3 install.py [--non-interactive | --reschedule | --uninstall | --purge]
python3 -m unittest discover -s tests -v- First run seeds silently — your backlog is adopted, not replayed.
- A delivery failure does not mark the PR seen, so the next cycle retries.
- Resolved requests are pruned, which is what lets a re-request notify again.
- State is per-machine; two machines each DM you once.
- Transient failures exit 0 — a closed laptop lid isn't a scheduler failure.
Run python3 poller.py --check first.
| Symptom | Cause |
|---|---|
| Works by hand, silent once scheduled | gh_path/claude_path unset — the stripped-PATH trap |
Schedule doesn't match config.json |
Needs install.py --reschedule |
| Auth error only when scheduled | Background job can't read gh's credential store; put a PAT in secrets.json |
| No team PRs found | Missing read:org |
| A query reports a refused count | Unresolvable team slug in config.json |
| Private repos return nothing | Token lacks repo, or needs SSO authorization |
claude did not confirm delivery |
Run --check --post-test to see the actual reply |
poller.log (self-truncating at 1 MB) holds the structured log on every platform. Anything escaping the logger goes to launchd-stderr.log on macOS, the journal (journalctl --user -u github-review-poller) on systemd, local mail under cron, and nowhere on Windows.