Skip to content

Instantly share code, notes, and snippets.

@peterfox
Last active April 15, 2026 13:48
Show Gist options
  • Select an option

  • Save peterfox/3c8cf9c23bef1ef8f010d0abf015c55d to your computer and use it in GitHub Desktop.

Select an option

Save peterfox/3c8cf9c23bef1ef8f010d0abf015c55d to your computer and use it in GitHub Desktop.
pr-monitor: GitHub PR watcher with macOS notifications and launchd support

Amiqus Scripts

A collection of developer scripts for macOS.


pr-monitor

Polls GitHub for pull requests you authored or are assigned to, and fires native macOS notifications when something changes — approvals, review requests, merges, closes, and new review activity.

How it works

On each run it:

  1. Fetches open and recently-closed PRs from each configured repo via the gh CLI.
  2. Filters to PRs where you are the author or an assignee.
  3. Compares against a local cache (~/.pr-checker/) to detect changes.
  4. Fires a single batched macOS notification summarising all updates (up to 4 shown inline, overflow counted).
  5. Updates the cache for the next run.

The first run silently seeds the cache so you don't get flooded with historical activity.

Prerequisites

Tool Install
Node.js (v18+) brew install node
tsx npm install -g tsx (or use npx tsx)
GitHub CLI brew install gh
alerter (recommended) brew install alerter

After installing gh, authenticate once:

gh auth login

Why alerter? macOS's built-in notification system (via osascript) doesn't support click actions — clicking a notification opens Script Editor. alerter fixes this so clicking a notification opens the PR in your browser. The script falls back to osascript if it isn't installed.

First-time setup

Run the interactive setup wizard:

npx tsx pr-monitor.ts --setup

It will:

  1. Check all required and optional dependencies are installed, with brew install hints for anything missing
  2. Detect your GitHub username from gh auth (or ask you to enter one)
  3. Let you browse repos by org/user name and toggle them on/off with a numbered list
  4. Let you repeat the browse step for multiple orgs
  5. Save everything to ~/.pr-checker/

Example session:

pr-monitor setup

Checking dependencies…

  ✓  gh                       /opt/homebrew/bin/gh
  ✓  npx                      /opt/homebrew/bin/npx
  ·  alerter (opt.)            not found — brew install alerter

GitHub username detected as "peter". Use this? [Y/n]:

Browse repos for org/user [peter]: amiqus
Fetching repos for "amiqus"… 14 found.

    1.  [ ]  amiqus/core
    2.  [ ]  amiqus/api
    3.  [ ]  amiqus/frontend
  ...

Enter numbers to toggle (space or comma separated), or Enter to skip: 1 2 3

  + added    amiqus/core
  + added    amiqus/api
  + added    amiqus/frontend

Currently watching (3):
  • amiqus/core
  • amiqus/api
  • amiqus/frontend

Browse another org/user? [y/N]:

After setup, run it once manually to seed the cache (no notifications fire on the first run):

npx tsx pr-monitor.ts

Usage

# Interactive configuration (username + repo selection)
npx tsx pr-monitor.ts --setup

# Normal run
npx tsx pr-monitor.ts

# Clear cache and re-seed (useful after a long absence)
npx tsx pr-monitor.ts --no-cache

# Install as a background service (polls every 5 minutes)
npx tsx pr-monitor.ts --install

# Remove the background service
npx tsx pr-monitor.ts --uninstall

# Fire a test notification to verify everything is working
npx tsx pr-monitor.ts --test-notification

Installing as a background service (launchd)

--install writes a plist to ~/Library/LaunchAgents/com.pr-monitor.plist and loads it with launchctl. It auto-detects the path to npx and captures your current PATH so the agent works regardless of how Node was installed (Homebrew, nvm, Volta, etc.).

npx tsx pr-monitor.ts --install

The agent polls every 5 minutes starting at your next login (or immediately after install). To remove it:

npx tsx pr-monitor.ts --uninstall

Other useful commands

# Watch logs in real time
tail -f /tmp/pr-monitor.log

# Reinstall after moving the script to a new path
npx tsx pr-monitor.ts --uninstall && npx tsx pr-monitor.ts --install

Cache files

State is stored in ~/.pr-checker/:

File Purpose
repos List of repos to monitor
username Your GitHub username
owner_repo.json Cached PR state per repo

Delete any .json file (or run with --no-cache) to force a fresh seed on the next run.

#!/usr/bin/env npx tsx
// pr-monitor.ts
// Polls GitHub PRs for a set of repos and fires macOS notifications on state changes (via alerter).
//
// Usage: npx tsx pr-monitor.ts
// npx tsx pr-monitor.ts --setup # interactive first-time configuration
// npx tsx pr-monitor.ts --install # install & load launchd agent
// npx tsx pr-monitor.ts --uninstall # unload & remove launchd agent
// npx tsx pr-monitor.ts --no-cache # clear cache and re-seed
// npx tsx pr-monitor.ts --test-notification # fire a sample notification
import { execSync } from "child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { join, resolve } from "path";
import { homedir } from "os";
import * as readline from "readline";
// ─── Configuration ────────────────────────────────────────────────────────────
const CACHE_DIR = join(homedir(), ".pr-checker");
const REPOS_FILE = join(CACHE_DIR, "repos");
const USERNAME_FILE = join(CACHE_DIR, "username");
const PR_LIMIT = 100; // covers open + recently closed PRs per repo
// ──────────────────────────────────────────────────────────────────────────────
interface Review {
author: { login: string };
state: string;
submittedAt: string;
}
interface PR {
number: number;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
url: string;
reviews: Review[];
author: { login: string };
assignees: { login: string }[];
}
function log(msg: string) {
console.log(`[${new Date().toLocaleTimeString()}] ${msg}`);
}
interface Update {
summary: string;
url: string;
}
const updates: Update[] = [];
function recordUpdate(summary: string, url: string) {
updates.push({ summary, url });
}
function flushNotification() {
if (updates.length === 0) return;
const title = `PR Monitor — ${updates.length} update${updates.length === 1 ? "" : "s"}`;
const body =
updates.slice(0, 4).map((u) => u.summary).join("\n") +
(updates.length > 4 ? `\n…and ${updates.length - 4} more` : "");
const firstUrl = updates[0].url;
// Requires alerter: brew install alerter
const notifierPath = execSync("which alerter", { encoding: "utf8" }).trim();
try {
const result = execSync(
`"${notifierPath}" --title ${JSON.stringify(title)} --message ${JSON.stringify(body)} --sound default`,
{ encoding: "utf8" }
).trim();
if (result === "@ACTIONCLICKED" || result === "@CONTENTCLICKED") {
execSync(`open ${JSON.stringify(firstUrl)}`);
}
} catch {
log(`[notify failed] ${title} — ${body}`);
}
}
function fetchPRs(repo: string): PR[] {
try {
const output = execSync(
`gh pr list --repo "${repo}" --state all --limit ${PR_LIMIT} --json number,title,state,reviewDecision,url,reviews,author,assignees`,
{ encoding: "utf8" }
);
return JSON.parse(output) as PR[];
} catch (err) {
log(`[${repo}] gh fetch failed — ${(err as Error).message.split("\n")[0]}`);
return [];
}
}
function cachePath(repo: string): string {
return join(CACHE_DIR, repo.replace("/", "_") + ".json");
}
function isMyPR(pr: PR, username: string): boolean {
return (
pr.author.login === username ||
pr.assignees.some((a) => a.login === username)
);
}
function checkRepo(repo: string, username: string) {
const allPRs = fetchPRs(repo);
const current = allPRs.filter((pr) => isMyPR(pr, username));
if (current.length === 0 && !existsSync(cachePath(repo))) return;
const path = cachePath(repo);
// First run: seed cache silently
if (!existsSync(path)) {
writeFileSync(path, JSON.stringify(current, null, 2));
log(`[${repo}] Cache seeded (${current.length} PRs)`);
return;
}
const previous: PR[] = JSON.parse(readFileSync(path, "utf8"));
const previousByNumber = new Map(previous.map((pr) => [pr.number, pr]));
for (const pr of current) {
const prev = previousByNumber.get(pr.number);
// New PR not seen before
if (!prev) {
if (pr.state === "OPEN") {
recordUpdate(`#${pr.number} opened: ${pr.title}`, pr.url);
log(`[${repo}] New PR #${pr.number}`);
}
continue;
}
// State changed (open → merged/closed)
if (pr.state !== prev.state) {
if (pr.state === "MERGED") {
recordUpdate(`#${pr.number} merged: ${pr.title}`, pr.url);
log(`[${repo}] #${pr.number} merged`);
} else if (pr.state === "CLOSED") {
recordUpdate(`#${pr.number} closed: ${pr.title}`, pr.url);
log(`[${repo}] #${pr.number} closed`);
}
}
// Only check review changes for open PRs
if (pr.state !== "OPEN") continue;
// Review decision changed
if (pr.reviewDecision !== prev.reviewDecision) {
if (pr.reviewDecision === "APPROVED") {
recordUpdate(`#${pr.number} approved: ${pr.title}`, pr.url);
log(`[${repo}] #${pr.number} approved`);
} else if (pr.reviewDecision === "CHANGES_REQUESTED") {
recordUpdate(`#${pr.number} changes requested: ${pr.title}`, pr.url);
log(`[${repo}] #${pr.number} changes requested`);
}
}
// New reviews submitted (catches activity before a final decision lands)
const newReviews = pr.reviews.length - prev.reviews.length;
if (newReviews > 0) {
recordUpdate(`#${pr.number} new review (+${newReviews}): ${pr.title}`, pr.url);
log(`[${repo}] #${pr.number} +${newReviews} review(s)`);
}
}
writeFileSync(path, JSON.stringify(current, null, 2));
}
function loadRepos(): string[] {
if (!existsSync(REPOS_FILE)) {
writeFileSync(
REPOS_FILE,
"# List repos to monitor, one per line:\n# owner/repo\n# amiqus/example-repo\n"
);
console.error(
`No repos file found. A template has been created at ${REPOS_FILE}\nAdd repos (owner/repo) and run again.`
);
process.exit(1);
}
return readFileSync(REPOS_FILE, "utf8")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("#"));
}
function loadUsername(): string {
if (!existsSync(USERNAME_FILE)) {
writeFileSync(USERNAME_FILE, "# Your GitHub username (one line, no @):\n# peter\n");
console.error(
`No username file found. A template has been created at ${USERNAME_FILE}\nAdd your GitHub username and run again.`
);
process.exit(1);
}
const username = readFileSync(USERNAME_FILE, "utf8")
.split("\n")
.map((l) => l.trim())
.find((l) => l.length > 0 && !l.startsWith("#"));
if (!username) {
console.error(`No username found in ${USERNAME_FILE}. Add your GitHub username and run again.`);
process.exit(1);
}
return username;
}
async function main() {
const repos = loadRepos();
const username = loadUsername();
if (repos.length === 0) {
console.error(`No repos found in ${REPOS_FILE}. Add some and run again.`);
process.exit(1);
}
log(`Checking PRs for @${username}…`);
for (const repo of repos) {
checkRepo(repo, username);
}
log("Done.");
flushNotification();
if (updates.length > 0) await new Promise((resolve) => setTimeout(resolve, 5000));
}
// ─── Dependency checks ───────────────────────────────────────────────────────
interface Dep {
name: string;
cmd: string;
required: boolean;
brewPkg: string;
}
const DEPS: Dep[] = [
{ name: "gh", cmd: "gh", required: true, brewPkg: "gh" },
{ name: "npx", cmd: "npx", required: true, brewPkg: "node" },
{ name: "alerter", cmd: "alerter", required: false, brewPkg: "alerter" },
];
function checkDependencies(): boolean {
console.log("Checking dependencies…\n");
let allRequired = true;
for (const dep of DEPS) {
let path = "";
try {
path = execSync(`which ${dep.cmd}`, { encoding: "utf8", stdio: "pipe" }).trim();
} catch { /* not found */ }
const found = path.length > 0;
const icon = found ? "✓" : (dep.required ? "✗" : "·");
const label = `${dep.name}${dep.required ? "" : " (optional)"}`;
const detail = found ? path : `not found — brew install ${dep.brewPkg}`;
console.log(` ${icon} ${label.padEnd(24)} ${detail}`);
if (!found && dep.required) allRequired = false;
}
console.log();
return allRequired;
}
// ─── Test notification ────────────────────────────────────────────────────────
function testNotification() {
updates.push({ summary: "#42 approved: Add dark mode support", url: "https://github.com" });
updates.push({ summary: "#38 new review (+1): Fix login redirect bug", url: "https://github.com" });
log("Firing test notification…");
flushNotification();
}
// ─── Setup wizard ────────────────────────────────────────────────────────────
function ask(rl: readline.Interface, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function setup() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log("\npr-monitor setup\n");
const depsOk = checkDependencies();
if (!depsOk) {
console.error("Required dependencies are missing. Install them and run --setup again.");
rl.close();
process.exit(1);
}
// ── Username ──────────────────────────────────────────────────────────────
let detectedUsername = "";
try {
detectedUsername = execSync("gh api user --jq .login", { encoding: "utf8" }).trim();
} catch {
/* gh not authed or unavailable */
}
let username = "";
if (detectedUsername) {
const answer = (await ask(rl, `GitHub username detected as "${detectedUsername}". Use this? [Y/n]: `)).trim();
if (answer.toLowerCase() === "n") {
username = (await ask(rl, "Enter your GitHub username: ")).trim();
} else {
username = detectedUsername;
}
} else {
username = (await ask(rl, "Enter your GitHub username: ")).trim();
}
if (!username) {
console.error("No username provided. Exiting.");
rl.close();
process.exit(1);
}
// ── Repo selection ────────────────────────────────────────────────────────
const watchedRepos: string[] = existsSync(REPOS_FILE)
? readFileSync(REPOS_FILE, "utf8")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("#"))
: [];
let browsing = true;
while (browsing) {
const orgInput = (await ask(rl, `\nBrowse repos for org/user [${username}]: `)).trim();
const org = orgInput || username;
process.stdout.write(`Fetching repos for "${org}"…`);
let repos: { nameWithOwner: string }[] = [];
try {
const out = execSync(`gh repo list "${org}" --limit 100 --json nameWithOwner`, { encoding: "utf8" });
repos = JSON.parse(out);
console.log(` ${repos.length} found.\n`);
} catch (err) {
console.log(" failed.");
console.error(` ${(err as Error).message.split("\n")[0]}`);
}
if (repos.length > 0) {
repos.forEach((r, i) => {
const watching = watchedRepos.includes(r.nameWithOwner);
console.log(` ${String(i + 1).padStart(3)}. [${watching ? "x" : " "}] ${r.nameWithOwner}`);
});
const selection = (
await ask(rl, "\nEnter numbers to toggle (space or comma separated), or Enter to skip: ")
).trim();
if (selection) {
const indices = selection
.split(/[\s,]+/)
.map((n) => parseInt(n, 10) - 1)
.filter((n) => !isNaN(n) && n >= 0 && n < repos.length);
for (const i of indices) {
const repo = repos[i].nameWithOwner;
const idx = watchedRepos.indexOf(repo);
if (idx !== -1) {
watchedRepos.splice(idx, 1);
console.log(` - removed ${repo}`);
} else {
watchedRepos.push(repo);
console.log(` + added ${repo}`);
}
}
}
}
if (watchedRepos.length > 0) {
console.log(`\nCurrently watching (${watchedRepos.length}):`);
watchedRepos.forEach((r) => console.log(` • ${r}`));
}
const more = (await ask(rl, "\nBrowse another org/user? [y/N]: ")).trim().toLowerCase();
browsing = more === "y";
}
rl.close();
if (watchedRepos.length === 0) {
console.error("\nNo repos selected — nothing saved. Run --setup again to configure.");
process.exit(1);
}
// ── Save ──────────────────────────────────────────────────────────────────
writeFileSync(USERNAME_FILE, `# Your GitHub username (one line, no @):\n${username}\n`);
writeFileSync(REPOS_FILE, `# Repos to monitor (owner/repo, one per line):\n${watchedRepos.join("\n")}\n`);
console.log(`\nSaved to ${CACHE_DIR}`);
console.log(" username : " + USERNAME_FILE);
console.log(" repos : " + REPOS_FILE);
console.log("\nRun the monitor:");
console.log(" npx tsx pr-monitor.ts");
console.log("\nOr install as a background service:");
console.log(" npx tsx pr-monitor.ts --install");
}
// ─── launchd helpers ──────────────────────────────────────────────────────────
const PLIST_LABEL = "com.pr-monitor";
const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
function buildPlist(scriptPath: string): string {
// Resolve npx from the current PATH so the plist works regardless of how
// Node was installed (Homebrew, nvm, Volta, etc.).
let npxPath = "/usr/local/bin/npx";
try {
npxPath = execSync("which npx", { encoding: "utf8" }).trim();
} catch {
// fall back to the default above
}
// Collect PATH entries that contain npx / node so launchd (which has a
// minimal default PATH) can find everything it needs at runtime.
const pathDirs = (process.env.PATH ?? "")
.split(":")
.filter((d) => d.length > 0);
const runtimePath = [...new Set([...pathDirs, "/usr/local/bin", "/usr/bin", "/bin"])].join(":");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>${PLIST_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${npxPath}</string>
<string>tsx</string>
<string>${scriptPath}</string>
</array>
<key>StartInterval</key> <integer>300</integer>
<key>StandardOutPath</key> <string>/tmp/pr-monitor.log</string>
<key>StandardErrorPath</key> <string>/tmp/pr-monitor.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${runtimePath}</string>
</dict>
</dict>
</plist>
`;
}
function install() {
const scriptPath = resolve(process.argv[1]);
if (existsSync(PLIST_PATH)) {
// Unload first so we can replace it cleanly
try { execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`); } catch { /* not loaded */ }
}
writeFileSync(PLIST_PATH, buildPlist(scriptPath));
execSync(`launchctl load "${PLIST_PATH}"`);
console.log(`Installed and loaded launchd agent.`);
console.log(` plist : ${PLIST_PATH}`);
console.log(` script: ${scriptPath}`);
console.log(` logs : tail -f /tmp/pr-monitor.log`);
console.log(`\nTo remove: npx tsx pr-monitor.ts --uninstall`);
}
function uninstall() {
if (!existsSync(PLIST_PATH)) {
console.error(`No plist found at ${PLIST_PATH} — nothing to uninstall.`);
process.exit(1);
}
try { execSync(`launchctl unload "${PLIST_PATH}"`); } catch { /* already unloaded */ }
rmSync(PLIST_PATH);
console.log(`Unloaded and removed launchd agent (${PLIST_PATH}).`);
}
// ─── Entry point ─────────────────────────────────────────────────────────────
mkdirSync(CACHE_DIR, { recursive: true });
if (process.argv.includes("--setup")) {
setup();
} else if (process.argv.includes("--test-notification")) {
testNotification();
} else if (process.argv.includes("--install")) {
install();
} else if (process.argv.includes("--uninstall")) {
uninstall();
} else {
if (process.argv.includes("--no-cache")) {
readdirSync(CACHE_DIR)
.filter((f) => f.endsWith(".json"))
.forEach((f) => {
rmSync(join(CACHE_DIR, f));
log(`Cleared cache: ${f}`);
});
}
main();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment