| name | github-monitor |
| description | Watch your GitHub repos across four views - a combined urgency monitor (stale PRs, new issues, releases), a new-issue triage queue, a release upgrade digest, or your own opened-PR tracker. |
| metadata | {"title":"GitHub Monitor","category":"dev","var":"","tags":["dev","meta","github"],"commits":false} |
${var} — View selector + optional scope.
- empty → combined monitor over every repo in
memory/watched-repos.md.
owner/repo (a bare repo, no view keyword) → combined monitor scoped to that one repo.
issues [scope] → new-issue triage queue. scope accepts owner/repo, org:foo, user:bar, or a bare login; empty = all repos owned by the authenticated user.
releases [repo,repo,…] → release upgrade-triage digest. Comma-separated repo list; empty = the built-in watch list.
prs → status tracker for PRs this aeon instance opened across external repos.
add-repo:<owner/repo> → append owner/repo to memory/watched-repos.md, confirm, and end (the shape the Telegram force-reply sends — see the config-capture note in Shared setup). No view runs.
This skill is four focused views of the same GitHub surface. The combined monitor is the default; issues, releases, and prs each drill into one dimension with the sibling view's own filtering, ranking, and output format. Only the monitor and issues views take a repo scope; releases takes a repo list; prs takes no scope (it reads its config from aeon.yml/env).
Shared setup (every view)
- Read
memory/MEMORY.md for high-level context.
- Read the last 2 days of
memory/logs/ — used for dedup in the monitor, issues, and releases views.
- Parse
${var} into a VIEW and a SCOPE:
RAW="$(printf '%s' "${var}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
case "$RAW" in
add-repo:*)
CAND="$(printf '%s' "${RAW#add-repo:}" \
| sed -e 's#^https\?://github.com/##' -e 's/^@//' -e 's/\.git$//' \
-e 's/^[[:space:]]*//' -e 's/[[:space:]].*$//')"
if ! printf '%s' "$CAND" | grep -qE '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; then
./notify "Couldn't read \"$CAND\" as a repo. Reply with owner/repo (e.g. acme/api)."
exit 0
fi
mkdir -p memory; touch memory/watched-repos.md
if grep -qiE "^[[:space:]]*-[[:space:]]*${CAND}[[:space:]]*$" memory/watched-repos.md; then
./notify "Already watching $CAND."
else
printf -- '- %s\n' "$CAND" >> memory/watched-repos.md
./notify "Now watching $CAND — it'll show up in the next GitHub Monitor run."
fi
exit 0 ;;
esac
if [ -z "$RAW" ]; then
VIEW=monitor; SCOPE=""
else
VIEW_TOKEN="$(printf '%s' "$RAW" | awk '{print tolower($1)}')"
SCOPE="$(printf '%s' "$RAW" | sed -E 's/^[^[:space:]]+[[:space:]]*//')"
case "$VIEW_TOKEN" in
issues|releases|prs) VIEW="$VIEW_TOKEN" ;;
*) VIEW=monitor; SCOPE="$RAW" ;;
esac
fi
- Dispatch to the matching view section below. Run exactly one view per invocation.
Selector examples: "" → monitor/all · anza-xyz/agave → monitor/one-repo · issues → issues/all · issues org:anthropics → issues/org · releases → releases/watch-list · releases anthropics/claude-code,openai/openai-python → releases/custom · prs → PR tracker.
Logging convention (all views): every view appends to memory/logs/${today}.md under the single heading ### github-monitor, and its first bullet is a discriminator naming the view that ran: - view: <monitor|issues|releases|prs> (var="${var}"). Keep the view-specific bullets exactly as described in each section — the identifiers/URLs they write are what the next run dedups against.
View: monitor (default — empty var, or a bare owner/repo scope)
Tiered urgency scan of PRs, new issues, and new releases across watched repos, with concrete next actions.
Config
Read repos from memory/watched-repos.md. If the file is missing or empty, offer to add the first repo via a Telegram force-reply, then log GITHUB_MONITOR_EMPTY_CONFIG (under ### github-monitor) and end. Send the offer only if no add-repo prompt was already offered in the last 2 days of memory/logs/ (dedup so an unconfigured fork isn't nagged every run):
./notify "No repos on the watchlist yet. Which repo should I watch? Reply with owner/repo." \
--force-reply --placeholder "owner/repo" \
--context "github-monitor::add-repo"
The reply routes back as var=add-repo:<owner/repo>, handled by the config-capture branch in Shared setup. Record FORCE_REPLY_OFFERED: add-repo in the log when you send it.
# memory/watched-repos.md
- owner/repo
- another-owner/another-repo
If SCOPE is set (a bare owner/repo), monitor only that repo. Otherwise monitor every entry in watched-repos.md.
1. Collect
For each repo, run these three gh calls. Capture the JSON; do not trust any shell expansion of untrusted fields.
Open PRs (full shape — the extra fields power the tier classifier):
gh pr list -R $repo --state open --limit 30 \
--json number,title,url,updatedAt,isDraft,reviewDecision,reviewRequests,statusCheckRollup,labels,author
Issues opened in the last 24h:
gh issue list -R $repo --state open --limit 20 \
--json number,title,url,createdAt,labels,author
Filter client-side to items where createdAt is within the last 24h.
Releases published in the last 24h (skip drafts and prereleases):
gh release list -R $repo --limit 5 --exclude-drafts --exclude-pre-releases \
--json tagName,publishedAt,name,url
Filter client-side to items where publishedAt is within the last 24h.
If any single gh call fails (network, auth, 404), record it as gh_error(<code>) for that repo and keep going — one repo's failure must not abort the whole run.
2. Classify into tiers
Walk every collected item and assign it to exactly one tier. Drop items that match no tier.
Tier precedence (when multiple criteria qualify, pick the highest): ACT NOW > REVIEW > INFO. Evaluate ACT NOW rules first; if any match, lock the tier and skip further checks for that item. Only fall through to REVIEW if no ACT NOW rule matched, and to INFO only if neither matched.
ACT NOW — needs a human decision today:
- Open PR, not draft, with any
statusCheckRollup[].conclusion == "FAILURE"
- Open PR, not draft,
reviewRequests non-empty, updatedAt older than 72h (reviewer ghosted)
- New issue whose labels match any of:
security, critical, p0, regression, outage, incident
- Release whose
tagName is a major bump vs. the previously logged tag (e.g. v2.0.0 after v1.*)
REVIEW — worth a look, not urgent:
- Open PR, not draft,
reviewDecision == "REVIEW_REQUIRED", updatedAt 48–72h ago
- Open PR, not draft,
mergeStateStatus/merge conflict markers flagged in statusCheckRollup
- New issue labelled
bug or p1
- Release that is a minor or patch bump
INFO — background signal:
- Other open, non-draft PRs with
updatedAt older than 48h
- New issue with no priority label
- Anything else passing the 24/48h windows
Drafts are never ACT NOW or REVIEW — at most INFO, and only if stale >7d. Do not alert on draft PRs just because they're idle.
Cap each tier at 5 items. If a tier would exceed 5, keep the top 5 by (tier rank, then most recently active) and append …and N more as the last bullet.
3. Dedup
Keep dedup simple — no escalation-history tracking:
- PRs: every run emits the PR's current tier. If an operator sees the same PR listed at the same tier day after day, that repetition is the intended signal (it has been sitting unresolved) — not noise.
- Issues:
${repo}!${number} — alert once, then skip in subsequent runs within the last 48h of logs.
- Releases:
${repo}@${tagName} — alert once, then skip in subsequent runs within the last 48h of logs.
Record each PR identifier and its assigned tier in the log (step 5) for traceability, but do not consult prior runs to gate PR re-emission.
4. Notify
Compose one consolidated ./notify message. Requirements:
- Verdict line first:
*GitHub Monitor* — N repos scanned, M need action (M = count of ACT NOW items).
- Skip any empty tier entirely (no
▶ ACT NOW header if zero items).
- Every bullet starts with an imperative verb (Review, Triage, Unblock, Merge, Note, Close) and ends with the item URL.
- Each bullet includes the one fact that justifies the tier (CI failing Nx, security label, reviewer idle Xh, major bump from v1.x, etc.) — not just the title.
- If any repo errored, append a single footer line:
sources: repoA=ok repoB=gh_error(404) — so the reader can see which repos were scanned vs. skipped.
Template:
*GitHub Monitor* — 4 repos scanned, 2 need action
▶ ACT NOW
• Review owner/repo#12 — CI failing 3×, author pinged 26h ago — <url>
• Triage owner/repo!30 — security label, opened 2h ago — <url>
▶ REVIEW
• Review owner/repo#15 — review requested, 50h idle — <url>
▶ INFO
• Note owner/repo v1.2.0 shipped (minor) — <url>
sources: owner/repo=ok another/repo=gh_error(404)
If every tier is empty, do not send a notification. Just log GITHUB_MONITOR_OK repos=N (step 5) and end. Silence is the correct signal when nothing changed.
5. Log
Append to memory/logs/${today}.md under the ### github-monitor heading (first bullet - view: monitor (var="${var}")):
- Tier counts:
ACT_NOW=N REVIEW=N INFO=N
- Each surfaced item's stable identifier and tier (plain lines like
owner/repo#12 ACT_NOW), so tomorrow's run can dedup and detect escalations.
sources: line mirroring the notification footer, including any gh_error(...) entries.
- If nothing was notified: a single line
GITHUB_MONITOR_OK repos=N.
- If
watched-repos.md was missing/empty: GITHUB_MONITOR_EMPTY_CONFIG.
- If all repo calls errored:
GITHUB_MONITOR_ERROR sources=... (do not notify in this case — silent failure to the user, visible failure in logs).
View: issues (issues [scope])
Digest of new open issues across your repos, ranked into a priority triage queue (security / bug / feature / other). Read-only by intent: this view reports; it does not label, comment on, or close issues.
Read the last 2 days of memory/logs/ and extract any GitHub issue URLs already alerted — these are dedup candidates.
Steps
-
Resolve the 24-hour window and the search scope from SCOPE:
YESTERDAY=$(date -u -d "yesterday" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -u -v-1d +%Y-%m-%dT%H:%M:%SZ)
ME=$(gh api user --jq .login)
if [ -z "$SCOPE" ]; then
ISCOPE="user:$ME"
else
case "$SCOPE" in
*:*) ISCOPE="$SCOPE" ;;
*/*) ISCOPE="repo:$SCOPE" ;;
*) ISCOPE="user:$SCOPE" ;;
esac
fi
-
Fetch every new open issue in scope with one advanced-search call (much cheaper than per-repo looping):
gh search issues --limit 100 \
--json number,title,url,createdAt,author,labels,repository,comments \
-- "$ISCOPE is:issue is:open created:>$YESTERDAY sort:created-desc" \
> /tmp/gh-issues.json
If the call fails (422 / rate-limit / transient), fall back to looping gh issue list -R <repo> over gh repo list "$ME" --limit 100 --json nameWithOwner,hasIssuesEnabled --jq '.[] | select(.hasIssuesEnabled) | .nameWithOwner', applying the same createdAt > $YESTERDAY filter via --jq.
-
Drop URLs already alerted in the previous 2 days of logs.
-
Rank each remaining issue into a priority bucket using its labels and title (case-insensitive regex):
- P0 — security/critical: any label or title matching
security|vuln|cve|exploit|critical|urgent|outage|p0
- P1 — bug/regression: matches
bug|regression|broken|crash|error|p1
- P2 — feature/enhancement: matches
feature|enhancement|feat|p2
- P3 — other: everything else (questions, docs, chores)
-
Sort within each bucket by comment count desc, then createdAt desc (more comments = more attention already drawn).
-
If the post-dedup, post-rank set is empty: send no notification. Skip directly to step 8.
-
Notify (gated) — format and send via ./notify. Skip empty buckets. Cap message at ~3500 chars; if over, truncate P3 first, then P2:
*GitHub Issues — ${today}*
<K> new issue(s) across <N> repo(s)
🔴 P0 — security/critical
• <repo> · #N Title (@author) [labels] — <url>
🟠 P1 — bugs
• <repo> · #N Title (@author) [labels] — <url>
🟡 P2 — features