Local Web dashboard for GitHub pull requests, built with Go/Gin, GORM/PostgreSQL and React/Vite. The application is under development; it is not yet ready for a shared production deployment. New logins use durable GitHub account identity with independent browser sessions. See follow-up operations for migration and the implementation status of reminders.
- Copy
.env.exampleto.env. Generate a unique encryption key withopenssl rand -hex 16and setTOKEN_ENCRYPTION_KEYto the resulting 32 characters. Keep this key stable across restarts and out of version control. - Create a GitHub App with user authorization enabled and callback URL
http://localhost:8080/api/v1/auth/github/callback. SetGITHUB_CLIENT_IDandGITHUB_CLIENT_SECRETin.env. - Run
docker compose up -d --buildto start PostgreSQL and the API. Compose loads the environment values from.env. - Run
bun install --frozen-lockfile && bun run dev. SetWEB_ORIGIN=http://localhost:5173andVITE_API_URL=http://localhost:8080for this development setup. Openhttp://localhost:5173and connect GitHub.
To run the API outside Docker, start PostgreSQL with docker compose up -d postgres and export DATABASE_URL, TOKEN_ENCRYPTION_KEY, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and WEB_ORIGIN into the API process environment before running cd apps/api && go run .. The Go binary does not automatically load .env files. Use the database connection settings in .env.example. PORT defaults to 8080.
If those ports are occupied, set PORT=8081, WEB_ORIGIN=http://localhost:5174, and GITHUB_REDIRECT_URL=http://localhost:8081/api/v1/auth/github/callback in .env. Register the same callback in GitHub, run docker compose up -d --build api, then start the frontend with cd apps/web && VITE_API_URL=http://localhost:8081 bun run dev --port 5174. The API container restarts automatically unless explicitly stopped. Check curl http://localhost:8081/health and docker compose ps when troubleshooting connectivity; open port 5174 for the dashboard.
make production builds the frontend with Bun and embeds its assets into the Go binary using standard go:embed. Compose runs one application container plus PostgreSQL. Open http://localhost:8080 (or the configured PORT). Set WEB_ORIGIN to that same browser-facing URL and GITHUB_REDIRECT_URL to its /api/v1/auth/github/callback; register this callback in GitHub. Existing development settings pointing to port 5174 must be changed when switching to the embedded UI.
make build produces dist/pr-desk. It includes the web assets and requires no frontend directory at runtime. Export the database, encryption key and OAuth environment before running it. The build uses -tags webembed; building with that tag without generated assets fails. Plain go run . and go test ./... remain available for API development without a frontend build. Vite remains the development server for hot reload.
The built frontend is a PWA: apps/web/public/manifest.webmanifest makes it installable and apps/web/public/sw.js is a service worker that caches the document and the hashed assets so a previously visited instance opens offline. Both are served from the root of the bundle because a service worker only controls the directory it is served from; registerWeb also registers the .webmanifest media type, which Go's built-in table lacks and the container image carries no /etc/mime.types to supply.
The worker never answers /api, /swagger, cross-origin or non-GET requests, so authentication and every mutation reach the server unchanged and an offline dashboard shows its normal network errors rather than stale data. Hashed assets are cached permanently and trimmed to the most recent 200 entries; the document is fetched from the network on every load and falls back to the cached copy only when the network fails, so a deployed build is picked up by the next page load without an update prompt. Registration is skipped outside production builds, so bun run dev is unaffected and no worker is ever installed on a development origin.
The shell cache is prdesk-shell-v2 and the asset cache prdesk-assets-v1; on activation the worker deletes every other cache whose name starts with prdesk-, which is also the prefix the in-app "Reload the app" button clears. Besides the document, the shell cache holds apps/web/public/theme-init.js, served network-first like the document, so an offline launch still applies the reader's theme.
The interface has a light and a dark theme. Every colour is a custom property on :root in apps/web/src/style.css; the dark values are written twice with identical bodies, once under @media (prefers-color-scheme: dark) for readers who have not chosen and once under :root[data-theme="dark"] for an explicit choice, and tokens.test.mjs fails if the two copies drift apart or if any text, border or focus pair drops below its WCAG AA ratio. Components use the mapped utilities (bg-bg, text-fg-muted, border-line-strong, text-tone-blocked and so on) and never a dark: variant or a literal colour; bun run lint:responsive refuses both.
The reader's choice (System, Light or Dark) is stored in localStorage["prdesk-theme"] by apps/web/src/theme.ts. apps/web/public/theme-init.js is a classic script loaded synchronously from index.html that sets data-theme and the two theme-color metas before the first paint; it is a file rather than inline because the API's Content-Security-Policy is default-src 'self'. The system font stack is used for the same reason: the policy allows no web fonts.
Tokens use AES-256-GCM with random nonces. Startup rejects missing, incorrectly sized, and example keys. Encryption, decryption, and database write failures do not produce successful connection responses. Existing AES-GCM records retain the same encoding. Legacy plaintext records and records encrypted with a different key require reconnecting GitHub; they are never sent to GitHub as bearer tokens.
Back up the encryption key separately from the database. Changing or losing it makes stored tokens unreadable. Key rotation with re-encryption is not implemented yet.
make test runs Go unit tests, frontend data-model regression tests, strict TypeScript checking, and the Vite production build. Frontend tests run through vp test (Vitest); dependencies remain managed by Bun 1.3.4. Token tests cover round trips, nonce uniqueness, malformed ciphertext, tampering, wrong keys, and invalid configuration. These checks do not prove live OAuth behavior, database migrations, or browser flows; end-to-end coverage is still required.
Cookie-authenticated mutation requests must include an Origin header matching WEB_ORIGIN exactly (including the port). Browsers send this automatically. CLI integrations must supply it explicitly. Requests with absent, opaque, or foreign origins return 403 before executing the operation. OAuth callback GET requests remain supported.
The dashboard searches PR title and normalized repository (owner/repo) with server-side filtering. Results are paged at 50 rows; search and filter changes reset to page one. The API accepts search, limit (1–200), and offset, and returns the filtered total.
The API also exposes GET /api/v1/repositories, scoped to the active session, with per-repository total, open, conflict, and needs-attention counts.
Synchronization reserves a PostgreSQL connection and holds a session advisory lock until the run finishes. For migrated accounts, replicas and browser sessions share the same durable account partition, so they reject overlapping syncs for that account with HTTP 409. Different accounts can sync independently. Legacy sessions retain their original partitions until verified reconnect. Database lock failures return HTTP 503. Cleanup uses an independent timeout after cancellation and discards connections whose unlock cannot be confirmed. Each active sync consumes one additional database connection, plus up to four more while it loads pull request details. The process caps its pool at 25 open and 8 idle connections and recycles a connection after 30 minutes, so a burst of first logins cannot exhaust PostgreSQL's own max_connections; DATABASE_MAX_OPEN_CONNS raises or lowers that ceiling, with a floor of 8 because a single sync needs that many to finish. Connect directly to PostgreSQL or use session pooling; transaction-mode connection proxies do not support these session locks.
Set TEST_DATABASE_URL to a test PostgreSQL database and run cd apps/api && go test -race ./... -count=1 to exercise database integration tests, including independent connection pools, cancellation cleanup and concurrent HTTP sync requests. Without this variable the database tests are skipped. Tests use temporary transactional schemas and do not modify application tables.
Install the PostgreSQL client tools, set DATABASE_URL, and run ./scripts/backup-postgres.sh /secure/path/pr-dashboard.dump. The script writes a custom-format dump with restrictive file permissions. It stages the dump next to the target path and promotes it only after pg_restore --list reads the archive, so a failed or unreadable run leaves any existing backup at that path untouched and removes its own partial file; ./scripts/backup-postgres-test.sh covers that behaviour with stub client tools. Restore into a new database with createdb target_db followed by pg_restore --clean --if-exists --no-owner --dbname="$TARGET_DATABASE_URL" /secure/path/pr-dashboard.dump; verify /health and the dashboard before switching traffic. Keep encryption-key backups separate from database backups because stored OAuth tokens cannot be decrypted without the same key.
For a timestamped backup through the project entry point, run make backup; override the path with BACKUP_FILE=/secure/path/pr-dashboard.dump make backup.
Sync reads the authenticated GitHub account creation date and fetches authored PRs across its history. Search intervals are split by creation timestamp whenever GitHub reports more than 1,000 matches or incomplete results. Each interval is paginated, deduplicated, and checked against the reported total; the SDK waits for primary rate-limit resets. The first full sync can take several minutes.
The overview offers every year from account creation through the current year, including years without PR activity. Yearly achievements use merge timestamps for merged PRs and creation timestamps for unmerged PRs (UTC). History completion is recorded only after the complete search snapshot is saved; partial caches are identified in the UI.
GitHub login does not grant a GitHub App access to private repositories. Configure the app's repository permissions (Pull requests: read, Checks: read, Commit statuses: read), then install it on each relevant personal account or organization. Choose all repositories or explicitly select the repositories to include. Existing installations must approve permission changes.
Set GITHUB_APP_SLUG to the app's GitHub slug to enable its direct installation
link. The overview reports accessible accounts, selected/all repository access,
and missing PR permissions. Returning from authorization rechecks access; a
transition to readable access triggers a sync. Private results remain limited
to repositories accessible to both the signed-in user and the installed app.
JavaScript tooling uses Vite+ 0.3.1 and Bun 1.3.4. From the repository root use vp install --frozen-lockfile, vp check, vp fmt, vp run dev, vp run build, and vp run test. In apps/web, vp dev, vp build, and vp test run directly invoke the web tools. The root bun.lock is the single dependency lockfile. Prettier is removed; Oxfmt and Oxlint configuration lives in root vite.config.ts.
Successful GitHub login immediately starts a background sync after saving the connection. It does not delay the redirect or depend on the callback request staying open. Existing checkpoints and advisory locks prevent duplicate work; server shutdown cancels the task. The periodic worker remains a fallback if the process stops before login-triggered work completes.
The API starts a robfig/cron worker automatically. Every 30 seconds it checks eligible connections and runs incremental syncs that are due, normally five minutes after the previous successful completion. The browser can be closed; the API and PostgreSQL must remain running. Failed runs wait at least 15 minutes, and first-time connections still import full history. New account credentials stop syncing after disconnection or a recorded authorization failure; expiring a browser session does not delete the account or its data. Expired legacy (30-day) sessions are excluded.
A GitHub App with "Expire user authorization tokens" enabled — the default for a newly created App — issues an access token valid for eight hours alongside a refresh token valid for six months. Both halves are stored encrypted, and accessTokenFor renews the access token in place before any GitHub call that would otherwise use a spent one, so the setting can be left on. Renewal is serialized per account with an advisory lock because GitHub retires the refresh token it was handed: two concurrent renewals would leave one worker holding a credential that no longer works. A renewal GitHub refuses records the same reconnect authorization failure a 401 always did, so the browser shows its existing banner. Accounts connected before this existed carry no refresh token and keep their previous behaviour until they reconnect once; an App with token expiry switched off issues no refresh token at all and is never renewed.
Automatic and manual runs share syncSession and PostgreSQL advisory locks. The checkpoint is checked again under the lock, so replicas do not repeat freshly completed work. A scan skips overlapping invocations and processes sessions serially to limit GitHub search pressure. Each run has a 30-minute timeout; server shutdown cancels active worker requests. Checkpoints and failure cooldowns survive restarts. The frontend only polls progress and refreshes cached views.