|
#!/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(); |
|
} |