From c0e46391e545b55ef65c745f721fc6826e38f2e7 Mon Sep 17 00:00:00 2001 From: Joshua Coles Date: Tue, 28 Jul 2026 14:32:31 +0000 Subject: [PATCH] Production deploy: rootDir fix, systemd unit, README, favicon Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ --- README.md | 58 +++++++++++++++++++++++++++++++++++++++ deploy/pr-monitor.service | 13 +++++++++ server/config.ts | 15 ++++++++-- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100644 deploy/pr-monitor.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..abaa930 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# pr-monitor + +Single-user dashboard for tracking PRs across multiple GitHub repos: CI checks +segmented into lint / specs / e2e / other (every check visible, with per-chip +dropdowns), review state derived from review-thread resolution, and a live +unified timeline of all comments and reviews with persistent read/unread state. + +## How it works + +- Node/TypeScript server polls the GitHub GraphQL API every 60s using a two-tier + query: a cheap `search()` list query whose fields double as change signals + (PR `updatedAt` does **not** change when checks or thread resolution change), + then batched detail queries only for changed PRs. +- State is cached in SQLite (`data/pr-monitor.sqlite`, `node:sqlite`) so the UI + loads instantly on restart; timeline events and read/unread survive restarts. +- The browser gets a `sync` event over SSE each poll and refetches via + TanStack Query. React 19 + Vite SPA, light/dark/system theme, responsive + (two-column desktop, tabbed mobile). + +## Config + +`config.json` — repos, the `involves:` filter flag, poll interval, port, +and per-repo check-name → category mappings (exact names or `*` globs, e.g. +`"Preview E2E *"`). Unmatched checks land in `other`; nothing is ever hidden. + +Auth: `GITHUB_TOKEN` env var, falling back to `gh auth token`. + +## Commands + +```sh +pnpm dev # server on :4000 (tsx watch) +pnpm dev:web # vite dev server on :5173, proxies /api +pnpm sync:once # one poll, prints a PR/category table (smoke test) +pnpm build # vite build + tsc server build +pnpm start # run the production build +``` + +## Deploy + +systemd user unit (survives reboots via linger): + +```sh +pnpm build +cp deploy/pr-monitor.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now pr-monitor +loginctl enable-linger $USER +``` + +Serves the built SPA + API on http://localhost:4000. + +## Notes + +- Own activity appears in the timeline but is never marked unread; the first-ever + sync marks the historical backfill as read (`BACKFILL_UNREAD=1` to override). +- Events are append-only: comments later deleted on GitHub remain in the timeline. +- Polling stretches to 5 min automatically when the GraphQL rate budget dips + below 500 points. diff --git a/deploy/pr-monitor.service b/deploy/pr-monitor.service new file mode 100644 index 0000000..e4209b7 --- /dev/null +++ b/deploy/pr-monitor.service @@ -0,0 +1,13 @@ +[Unit] +Description=PR monitor dashboard +After=network-online.target + +[Service] +WorkingDirectory=/home/apps/vibes/pr-monitor +ExecStart=/home/apps/.local/bin/mise exec -- node dist-server/server/index.js +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production + +[Install] +WantedBy=default.target diff --git a/server/config.ts b/server/config.ts index 3bcb5ce..82f97ae 100644 --- a/server/config.ts +++ b/server/config.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { z } from 'zod'; @@ -54,7 +54,18 @@ function compileNameGlob(pattern: string): (name: string) => boolean { return (name) => regex.test(name); } -export const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +/** Project root: walk up from this module (which may live in server/ or + * dist-server/server/) until config.json appears. */ +function findRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 4; i++) { + dir = dirname(dir); + if (existsSync(join(dir, 'config.json'))) return dir; + } + return process.cwd(); +} + +export const rootDir = findRoot(); export function loadConfig(path = join(rootDir, 'config.json')): AppConfig { const raw = configSchema.parse(JSON.parse(readFileSync(path, 'utf8')));