github-poller — watch GitHub repos for activity
This is an opt-in poller skill that ships with mimir under
mimir/optional-skills/ but is NOT auto-installed. Most installs
won't watch a GitHub repo, so the framework doesn't seed it by default.
Installation
Copy the directory into your agent home:
cp -r mimir/optional-skills/github-poller <home>/skills/
(Or run from inside the container against /workspace/mimir.)
Configure env vars:
| Variable |
Required |
Description |
GITHUB_REPOS |
yes |
Comma-separated owner/repo list (e.g. jasoncarreira/mimir,jasoncarreira/mimirbot) |
GITHUB_TOKEN |
recommended |
GitHub PAT. Falls back to gh auth token when unset. |
MIMIR_GITHUB_SELF_LOGIN |
optional |
GitHub login mimir authors as. Events from this login get filtered out (mimir doesn't wake itself). Leave empty if mimir uses the operator's PAT — the operator is the signal you want, not the noise. |
These env vars must be exported in mimir's process environment
(e.g. via the container's env file). The framework gates subprocess
env keys two ways: a built-in allowlist (PATH, HOME,
locale, XDG, CA bundles — everything else is stripped) plus a deny
filter on top (*_TOKEN, *_SECRET, *_API_KEY,
*_PASSWORD, MIMIR_* are stripped even if allowlisted).
The pass_env field in this skill's pollers.json declares
all three keys above as explicit pass-throughs, bypassing both
gates — different mechanism per key:
GITHUB_TOKEN — matches *_TOKEN deny pattern → pass_env
bypasses the deny filter.
MIMIR_GITHUB_SELF_LOGIN — matches MIMIR_* deny pattern →
pass_env bypasses the deny filter.
GITHUB_REPOS — not a secret, but not in the built-in
allowlist either → pass_env bypasses the allowlist gate. Same
end result (key reaches the subprocess), just gated differently.
If you rename or relocate a var, update pollers.json
accordingly. GITHUB_TOKEN set but not declared in pass_env
→ silently absent in the subprocess → poller falls through to
gh auth token → zero events forever if gh isn't authed.
Bring it live:
reload_pollers
# → "reload_pollers ok: N poller(s) registered — github-activity, ..."
(Or restart the container; pollers are auto-loaded at startup.)
What it watches
For each repo in GITHUB_REPOS:
- New issues opened (since last poll)
- New pull requests opened
- New issue comments (covers both issue + PR conversation comments)
- New PR review comments (inline diff comments — distinct from conversation comments)
- New PR reviews (approve / changes-requested / commented with body)
- PR pushes/updates (
pr_synchronize) — new commits pushed to an existing open PR.
The poller calls GET /repos/{repo}/compare/{prev}...{new} to fetch up to 3
commit subjects inline. If multiple commits land between polls, all are
surfaced (total count + first 3 subjects + "… N more" when truncated).
Force-pushes that change the SHA but not the diff also fire — this is a
known false-positive; compare-diff diffing on every poll is too expensive.
- Review requests (
pr_review_requested) — fired when MIMIR_GITHUB_SELF_LOGIN
appears in a PR's requested_reviewers. State-reconciling retry (chainlink
#299): a submitted review removes you from requested_reviewers, so while you
remain requested the poller RE-EMITS once per poll (up to
REVIEW_REQUEST_MAX_ATTEMPTS, default 3). This recovers a review whose
triggered turn died (e.g. a transient model 503) instead of silently dropping
it — the old emit-once model advanced the cursor past a dead turn and never
re-fired (observed on PR #511). On exhaustion it emits a one-shot
pr_review_request_gave_up signal (no turn; surfaces as a negative
algedonic signal via feedback.classify's *_gave_up rule) and goes dormant
for that PR until you're removed and re-requested. If an operator re-requests
you after you already submitted a substantive review (APPROVED,
CHANGES_REQUESTED, or COMMENTED) at the current head SHA, the poller
treats that request as satisfied and suppresses both retry emits and give-up
signals.
- Stale changes-requested on your own PRs (
pr_changes_requested_stale) —
state-reconciling reminder (chainlink #449), the reverse direction of the
above: reviews ON your PRs are otherwise edge-triggered only, so a turn that
reads a request-changes review without pushing fixes loses the work signal
when the cursor advances. Each poll, your open PRs whose latest substantive
review per reviewer is CHANGES_REQUESTED and whose reviewed diff has
not materially changed are reminded on an hourly elapsed-time cadence, with a
one-minute allowance for per-run timestamp jitter. Polls before the interval
expires do not refresh the timestamp. A substantive fix, later approval
clearing all blocking reviews, or PR close/merge stops reminders; a
content-free rebase with the same patch does not. Reminder emissions use a
bounded attempt series and a one-shot give-up signal; because queued emissions
are not proof of delivery, an unchanged unresolved PR starts a fresh series
after a 24-hour backstop rather than staying silent forever.
- Mergeability of your own PRs — GitHub's per-PR
mergeable result is
reconciled with the declared base/head comparison. A clean branch behind its
base emits pr_mergeability_rebase, which uses the existing scoped checkout,
rebases onto the declared base, runs tests, and pushes with a lease against the
observed head. The automatic path is restricted to PRs with no blocking
CHANGES_REQUESTED review, so moving the head cannot silently discharge
unaddressed feedback. A conflict emits pr_mergeability_conflicting; its
scope cannot push and the turn must reproduce the conflict, name all unmerged
paths and the base SHA, then abort and escalate. A null mergeability result
is asynchronous/unknown and is never acted upon. Attempts use hourly backoff,
are bounded per PR/head/reason, and end in the named
pr_mergeability_rebase_gave_up signal. At most one such attempt is emitted
across all repositories in a poll cycle. This path never merges a PR.
- Completed check failures on open PRs (
pr_ci_failure) — a newly failed
check set on a PR authored by MIMIR_GITHUB_SELF_LOGIN starts one remediation
turn bound to the repository, PR number, immutable head SHA, failed check IDs,
names, conclusions, and log URLs. The repository checkout preflight re-fetches
the PR and checks and stops before mutation when the PR closed, the head was
superseded, or a rerun is green. Failures on external contributors' PRs emit a
pr_ci_failure_external notification signal only and never receive repository
mutation authority. CI remediation retains pr_comment authority so blocked
or failed repairs can leave an operator-visible trace, but excludes pr_edit
and pr_rerequest. Stable delivery keys and framework receipts dedupe
overlapping polls while allowing a rejected handoff to retry safely. On
rollout, a head whose failures predate the first observation is deliberately
baselined and remains quiet until its head or failure set changes. Open-PR
discovery is intentionally bounded to the 100 most recently updated PRs; a
newly completed check normally refreshes the affected PR into that window.
Issue / PR / comment / review detection is filtered by created_at > cursor
and (when set) user.login != MIMIR_GITHUB_SELF_LOGIN. Push + review-request
detection are state-based (head-SHA delta / requested_reviewers membership),
not created_at-windowed.
What it doesn't watch (deliberate)
- Commits — already handled by
git pull. No GitHub-API path for "new commit" wakes today.
- Issue/PR state changes (close, reopen, merge, label) — adds noise; revisit if it becomes useful.
- Main-branch workflow failures — remain the separate
github-ci-watch
responsibility; this poller only handles checks attached to open PR heads.
- Notifications API (
/notifications) — an alternative path that's higher-noise; this poller takes the targeted-endpoint approach instead.
Why login-based, not email-based
GitHub's API events expose user.login for issues, PRs, comments, and reviews — never the author's commit email. Email-based filtering (e.g. noreply@mimir-agent.local) is the right key for commits but not for the API surface.
Cost
Polling 1 repo every 15 min @ 4 endpoints = ~16 calls/hr/repo. For a 5-repo watch that's ~80/hr — well under GitHub's 5000/hr per-PAT rate limit. The since= query param keeps each call's payload small.
Batching
pollers.json sets batch_size=1: each independent GitHub item gets its own turn and full tool budget. This deliberately avoids combining multiple PR reviews or remediations into one turn whose shared budget can strand later items. Tune only with care: larger batches reduce turn count but couple unrelated GitHub work to one execution budget.
Cursor file
Persists at <home>/state/pollers/github-activity/cursor.json (the framework-injected STATE_DIR). Survives container rebuilds. First run looks back 1 hour to bound the backfill burst.
The pr_changes_requested key maps {repo: {pr_number: {"head_sha": sha, "last_reminded_at": ISO-8601 UTC timestamp, "attempts": count}}}. It tracks unresolved own-PR stale states, the hourly reminder floor, and bounded attempt series while the live snapshot remains semantically blocked. Attempts are charged from framework-written turn outcomes, not reminder emission. Authorization and tool-boundary refusals are reported but exempt from the content-failure budget and retry only at the 24-hour backstop, preventing a permanent configuration fault from firing at poll cadence. At the cap it emits one pr_changes_requested_gave_up signal with the charged outcome reasons; a head change re-arms immediately, and an unchanged parked entry re-arms after the same 24-hour delivery-loss backstop. Closed, unblocked, or substantively changed PRs drop out. Pre-cadence bare-string and structured-without-attempts entries migrate quietly as attempt 1 with last_reminded_at set to that poll's time, preventing an upgrade-time emit storm.
The pr_review_requests key maps {repo: {pr_number: attempts}} — attempts counts pr_review_requested emits while you stayed requested (chainlink #299; a dormant PR that gave up parks at cap + 1). The pre-#299 bare-list format ({repo: [pr_number, ...]}) migrates automatically on first load.
The pr_mergeability key maps {repo: {pr_number: {head_sha, reason, last_attempt_at, attempts}}}. A new head or changed failure reason starts a fresh bounded series; current and closed PRs drop out during snapshot rebuild.
The pr_ci_failures key maps each live red PR to its immutable head, stable
failure-set delivery key, and last emit time. Framework acknowledgements live in
<home>/state/pollers/github-activity/.delivery-receipts/; a receipt is created
only after enqueue or external-signal logging succeeds. A missing receipt makes
the same claim retry-eligible after five minutes, while atomic files under
.delivery-claims/ suppress concurrent poll overlap.
Disabling temporarily
- Unset
GITHUB_REPOS — the poller exits silently.
- Or set the cron in
pollers.json to a far-future expression.
- Or remove the skill directory:
rm -rf <home>/skills/github-poller/ + reload_pollers.
Debugging
| Symptom |
Check |
| Poller not firing |
events.jsonl for poller_complete{poller=github-activity} — is it appearing on schedule? |
| No events from a repo where you expected one |
events.jsonl for poller_stderr from this poller — auth or rate-limit issues land here |
| Too many wake-ups |
Set MIMIR_GITHUB_SELF_LOGIN to mimir's GitHub login (when mimir gets its own bot account) so its own comments stop firing the poller |
| Cursor stuck |
cat <home>/state/pollers/github-activity/cursor.json — should be a recent ISO timestamp |
1---2name: github-poller3description: Watch one or more GitHub repositories for new issues, pull requests, comments, PR reviews, and inline diff comments — emits a fresh turn each time something actionable happens. Use when you've opened PRs / issues mimir should know about review activity for, when you want operator-actionable signals for repos mimir maintains, or when you need to track upstream activity on a watched repo. Filters out events authored by mimir's own GitHub identity (set ``MIMIR_GITHUB_SELF_LOGIN``) so the bot doesn't wake itself with its own comments. Companion to the ``pollers`` framework skill (mechanics) and the ``world-scanning`` skill (catalog of *what's worth polling*). Opt-in: copy this directory into ``<home>/skills/github-poller/`` then set the env vars below.4---56# github-poller — watch GitHub repos for activity78This is an **opt-in poller skill** that ships with mimir under9``mimir/optional-skills/`` but is NOT auto-installed. Most installs10won't watch a GitHub repo, so the framework doesn't seed it by default.1112## Installation13141. Copy the directory into your agent home:15 ```16 cp -r mimir/optional-skills/github-poller <home>/skills/17 ```18 (Or run from inside the container against ``/workspace/mimir``.)19202. Configure env vars:2122 | Variable | Required | Description |23 |---|---|---|24 | `GITHUB_REPOS` | yes | Comma-separated `owner/repo` list (e.g. `jasoncarreira/mimir,jasoncarreira/mimirbot`) |25 | `GITHUB_TOKEN` | recommended | GitHub PAT. Falls back to `gh auth token` when unset. |26 | `MIMIR_GITHUB_SELF_LOGIN` | optional | GitHub login mimir authors as. Events from this login get filtered out (mimir doesn't wake itself). Leave empty if mimir uses the operator's PAT — the operator is the signal you want, not the noise. |2728 These env vars must be exported in mimir's process environment29 (e.g. via the container's env file). The framework gates subprocess30 env keys two ways: a built-in allowlist (``PATH``, ``HOME``,31 locale, XDG, CA bundles — everything else is stripped) plus a deny32 filter on top (``*_TOKEN``, ``*_SECRET``, ``*_API_KEY``,33 ``*_PASSWORD``, ``MIMIR_*`` are stripped even if allowlisted).34 The ``pass_env`` field in this skill's ``pollers.json`` declares35 all three keys above as explicit pass-throughs, bypassing both36 gates — different mechanism per key:37 - ``GITHUB_TOKEN`` — matches ``*_TOKEN`` deny pattern → `pass_env`38 bypasses the deny filter.39 - ``MIMIR_GITHUB_SELF_LOGIN`` — matches ``MIMIR_*`` deny pattern →40 `pass_env` bypasses the deny filter.41 - ``GITHUB_REPOS`` — not a secret, but not in the built-in42 allowlist either → `pass_env` bypasses the allowlist gate. Same43 end result (key reaches the subprocess), just gated differently.44 If you rename or relocate a var, update ``pollers.json``45 accordingly. ``GITHUB_TOKEN`` set but not declared in ``pass_env``46 → silently absent in the subprocess → poller falls through to47 ``gh auth token`` → zero events forever if ``gh`` isn't authed.48493. Bring it live:50 ```51 reload_pollers52 # → "reload_pollers ok: N poller(s) registered — github-activity, ..."53 ```54 (Or restart the container; pollers are auto-loaded at startup.)5556## What it watches5758For each repo in `GITHUB_REPOS`:5960- **New issues** opened (since last poll)61- **New pull requests** opened62- **New issue comments** (covers both issue + PR conversation comments)63- **New PR review comments** (inline diff comments — distinct from conversation comments)64- **New PR reviews** (approve / changes-requested / commented with body)65- **PR pushes/updates** (`pr_synchronize`) — new commits pushed to an existing open PR.66 The poller calls `GET /repos/{repo}/compare/{prev}...{new}` to fetch up to 367 commit subjects inline. If multiple commits land between polls, all are68 surfaced (total count + first 3 subjects + "… N more" when truncated).69 Force-pushes that change the SHA but not the diff also fire — this is a70 known false-positive; compare-diff diffing on every poll is too expensive.71- **Review requests** (`pr_review_requested`) — fired when `MIMIR_GITHUB_SELF_LOGIN`72 appears in a PR's `requested_reviewers`. **State-reconciling retry** (chainlink73 #299): a submitted review removes you from `requested_reviewers`, so while you74 remain requested the poller RE-EMITS once per poll (up to75 `REVIEW_REQUEST_MAX_ATTEMPTS`, default 3). This recovers a review whose76 triggered turn died (e.g. a transient model 503) instead of silently dropping77 it — the old emit-once model advanced the cursor past a dead turn and never78 re-fired (observed on PR #511). On exhaustion it emits a one-shot79 `pr_review_request_gave_up` **signal** (no turn; surfaces as a *negative80 algedonic signal* via `feedback.classify`'s `*_gave_up` rule) and goes dormant81 for that PR until you're removed and re-requested. If an operator re-requests82 you after you already submitted a substantive review (`APPROVED`,83 `CHANGES_REQUESTED`, or `COMMENTED`) at the current head SHA, the poller84 treats that request as satisfied and suppresses both retry emits and give-up85 signals.86- **Stale changes-requested on your own PRs** (`pr_changes_requested_stale`) —87 state-reconciling reminder (chainlink #449), the reverse direction of the88 above: reviews ON your PRs are otherwise edge-triggered only, so a turn that89 reads a request-changes review without pushing fixes loses the work signal90 when the cursor advances. Each poll, your open PRs whose latest substantive91 review per reviewer is `CHANGES_REQUESTED` **and** whose reviewed diff has92 not materially changed are reminded on an hourly elapsed-time cadence, with a93 one-minute allowance for per-run timestamp jitter. Polls before the interval94 expires do not refresh the timestamp. A substantive fix, later approval95 clearing all blocking reviews, or PR close/merge stops reminders; a96 content-free rebase with the same patch does not. Reminder emissions use a97 bounded attempt series and a one-shot give-up signal; because queued emissions98 are not proof of delivery, an unchanged unresolved PR starts a fresh series99 after a 24-hour backstop rather than staying silent forever.100- **Mergeability of your own PRs** — GitHub's per-PR `mergeable` result is101 reconciled with the declared base/head comparison. A clean branch behind its102 base emits `pr_mergeability_rebase`, which uses the existing scoped checkout,103 rebases onto the declared base, runs tests, and pushes with a lease against the104 observed head. The automatic path is restricted to PRs with no blocking105 `CHANGES_REQUESTED` review, so moving the head cannot silently discharge106 unaddressed feedback. A conflict emits `pr_mergeability_conflicting`; its107 scope cannot push and the turn must reproduce the conflict, name all unmerged108 paths and the base SHA, then abort and escalate. A `null` mergeability result109 is asynchronous/unknown and is never acted upon. Attempts use hourly backoff,110 are bounded per PR/head/reason, and end in the named111 `pr_mergeability_rebase_gave_up` signal. At most one such attempt is emitted112 across all repositories in a poll cycle. This path never merges a PR.113- **Completed check failures on open PRs** (`pr_ci_failure`) — a newly failed114 check set on a PR authored by `MIMIR_GITHUB_SELF_LOGIN` starts one remediation115 turn bound to the repository, PR number, immutable head SHA, failed check IDs,116 names, conclusions, and log URLs. The repository checkout preflight re-fetches117 the PR and checks and stops before mutation when the PR closed, the head was118 superseded, or a rerun is green. Failures on external contributors' PRs emit a119 `pr_ci_failure_external` notification signal only and never receive repository120 mutation authority. CI remediation retains `pr_comment` authority so blocked121 or failed repairs can leave an operator-visible trace, but excludes `pr_edit`122 and `pr_rerequest`. Stable delivery keys and framework receipts dedupe123 overlapping polls while allowing a rejected handoff to retry safely. On124 rollout, a head whose failures predate the first observation is deliberately125 baselined and remains quiet until its head or failure set changes. Open-PR126 discovery is intentionally bounded to the 100 most recently updated PRs; a127 newly completed check normally refreshes the affected PR into that window.128129Issue / PR / comment / review detection is filtered by `created_at > cursor`130and (when set) `user.login != MIMIR_GITHUB_SELF_LOGIN`. Push + review-request131detection are state-based (head-SHA delta / `requested_reviewers` membership),132not `created_at`-windowed.133134## What it doesn't watch (deliberate)135136- **Commits** — already handled by `git pull`. No GitHub-API path for "new commit" wakes today.137- **Issue/PR state changes** (close, reopen, merge, label) — adds noise; revisit if it becomes useful.138- **Main-branch workflow failures** — remain the separate `github-ci-watch`139 responsibility; this poller only handles checks attached to open PR heads.140- **Notifications API** (`/notifications`) — an alternative path that's higher-noise; this poller takes the targeted-endpoint approach instead.141142## Why login-based, not email-based143144GitHub's API events expose `user.login` for issues, PRs, comments, and reviews — never the author's commit email. Email-based filtering (e.g. `noreply@mimir-agent.local`) is the right key for **commits** but not for the API surface.145146## Cost147148Polling 1 repo every 15 min @ 4 endpoints = ~16 calls/hr/repo. For a 5-repo watch that's ~80/hr — well under GitHub's 5000/hr per-PAT rate limit. The `since=` query param keeps each call's payload small.149150## Batching151152`pollers.json` sets `batch_size=1`: each independent GitHub item gets its own turn and full tool budget. This deliberately avoids combining multiple PR reviews or remediations into one turn whose shared budget can strand later items. Tune only with care: larger batches reduce turn count but couple unrelated GitHub work to one execution budget.153154## Cursor file155156Persists at `<home>/state/pollers/github-activity/cursor.json` (the framework-injected `STATE_DIR`). Survives container rebuilds. First run looks back 1 hour to bound the backfill burst.157158The `pr_changes_requested` key maps `{repo: {pr_number: {"head_sha": sha, "last_reminded_at": ISO-8601 UTC timestamp, "attempts": count}}}`. It tracks unresolved own-PR stale states, the hourly reminder floor, and bounded attempt series while the live snapshot remains semantically blocked. Attempts are charged from framework-written turn outcomes, not reminder emission. Authorization and tool-boundary refusals are reported but exempt from the content-failure budget and retry only at the 24-hour backstop, preventing a permanent configuration fault from firing at poll cadence. At the cap it emits one `pr_changes_requested_gave_up` signal with the charged outcome reasons; a head change re-arms immediately, and an unchanged parked entry re-arms after the same 24-hour delivery-loss backstop. Closed, unblocked, or substantively changed PRs drop out. Pre-cadence bare-string and structured-without-`attempts` entries migrate quietly as attempt 1 with `last_reminded_at` set to that poll's time, preventing an upgrade-time emit storm.159160The `pr_review_requests` key maps `{repo: {pr_number: attempts}}` — `attempts` counts `pr_review_requested` emits while you stayed requested (chainlink #299; a dormant PR that gave up parks at `cap + 1`). The pre-#299 bare-list format (`{repo: [pr_number, ...]}`) migrates automatically on first load.161162The `pr_mergeability` key maps `{repo: {pr_number: {head_sha, reason, last_attempt_at, attempts}}}`. A new head or changed failure reason starts a fresh bounded series; current and closed PRs drop out during snapshot rebuild.163164The `pr_ci_failures` key maps each live red PR to its immutable head, stable165failure-set delivery key, and last emit time. Framework acknowledgements live in166`<home>/state/pollers/github-activity/.delivery-receipts/`; a receipt is created167only after enqueue or external-signal logging succeeds. A missing receipt makes168the same claim retry-eligible after five minutes, while atomic files under169`.delivery-claims/` suppress concurrent poll overlap.170171## Disabling temporarily172173- Unset `GITHUB_REPOS` — the poller exits silently.174- Or set the cron in `pollers.json` to a far-future expression.175- Or remove the skill directory: `rm -rf <home>/skills/github-poller/` + `reload_pollers`.176177## Debugging178179| Symptom | Check |180|---|---|181| Poller not firing | `events.jsonl` for `poller_complete{poller=github-activity}` — is it appearing on schedule? |182| No events from a repo where you expected one | `events.jsonl` for `poller_stderr` from this poller — auth or rate-limit issues land here |183| Too many wake-ups | Set `MIMIR_GITHUB_SELF_LOGIN` to mimir's GitHub login (when mimir gets its own bot account) so its own comments stop firing the poller |184| Cursor stuck | `cat <home>/state/pollers/github-activity/cursor.json` — should be a recent ISO timestamp |