From 05ac4a3a999d30a75db2aabd17e0e45aad97650b Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:11:41 +0200 Subject: [PATCH 01/10] packages: drop @github-dashboard/ prefix from workspace names Workspace packages are private and only referenced via pnpm --filter and workspace:* links, so the scope adds noise without value. Rename to bare desktop/server/web and update all --filter invocations, the desktop workspace dep link, and the app.setName comment. --- .github/workflows/desktop-ci.yml | 4 ++-- .github/workflows/desktop-release.yml | 4 ++-- package.json | 8 ++++---- packages/desktop/package.json | 4 ++-- packages/desktop/src/main.ts | 6 +++--- packages/server/package.json | 2 +- packages/web/package.json | 2 +- pnpm-lock.yaml | 6 +++--- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index af41d7f..d242577 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -23,10 +23,10 @@ jobs: - run: pnpm build - - run: pnpm --filter @github-dashboard/desktop build + - run: pnpm --filter desktop build - name: Package (unsigned, dir-only) - run: pnpm --filter @github-dashboard/desktop package:dir + run: pnpm --filter desktop package:dir env: # Skip code-signing in CI — we just want to validate the package step. CSC_IDENTITY_AUTO_DISCOVERY: "false" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 876b25c..eb6deee 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -131,7 +131,7 @@ jobs: - run: pnpm build - - run: pnpm --filter @github-dashboard/desktop build + - run: pnpm --filter desktop build # Always publish to a draft first. GitHub's immutable-releases behavior # locks published releases (including pre-releases) against further @@ -139,7 +139,7 @@ jobs: # create-then-upload-assets sequence electron-builder uses. Drafts are # mutable; the promote step below flips it to pre-release after uploads. - name: Package + publish (draft) - run: pnpm --filter @github-dashboard/desktop package --publish always + run: pnpm --filter desktop package --publish always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Code signing is not yet configured. Until certs are added, builds diff --git a/package.json b/package.json index 76e4bb5..f07eec8 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "private": true, "packageManager": "pnpm@10.29.3", "scripts": { - "dev": "mkdir -p .logs && pnpm --filter @github-dashboard/desktop build && concurrently --kill-others -n server,web,desktop -c blue,green,magenta \"pnpm --filter @github-dashboard/server dev 2>&1 | tee .logs/server.log\" \"pnpm --filter @github-dashboard/web dev 2>&1 | tee .logs/web.log\" \"wait-on http://localhost:7200 && pnpm --filter @github-dashboard/desktop dev 2>&1 | tee .logs/desktop.log\"", - "dev:web": "mkdir -p .logs && concurrently --kill-others -n server,web -c blue,green \"pnpm --filter @github-dashboard/server dev 2>&1 | tee .logs/server.log\" \"pnpm --filter @github-dashboard/web dev 2>&1 | tee .logs/web.log\"", + "dev": "mkdir -p .logs && pnpm --filter desktop build && concurrently --kill-others -n server,web,desktop -c blue,green,magenta \"pnpm --filter server dev 2>&1 | tee .logs/server.log\" \"pnpm --filter web dev 2>&1 | tee .logs/web.log\" \"wait-on http://localhost:7200 && pnpm --filter desktop dev 2>&1 | tee .logs/desktop.log\"", + "dev:web": "mkdir -p .logs && concurrently --kill-others -n server,web -c blue,green \"pnpm --filter server dev 2>&1 | tee .logs/server.log\" \"pnpm --filter web dev 2>&1 | tee .logs/web.log\"", "demo": "DEMO=1 pnpm dev", "demo:web": "DEMO=1 pnpm dev:web", - "build": "pnpm --filter @github-dashboard/server build && pnpm --filter @github-dashboard/web build", - "build:desktop": "pnpm build && pnpm --filter @github-dashboard/desktop build && pnpm --filter @github-dashboard/desktop package", + "build": "pnpm --filter server build && pnpm --filter web build", + "build:desktop": "pnpm build && pnpm --filter desktop build && pnpm --filter desktop package", "lint": "oxlint", "fmt": "oxfmt .", "fmt:check": "oxfmt --check .", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6255de6..5af6d50 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,5 +1,5 @@ { - "name": "@github-dashboard/desktop", + "name": "desktop", "productName": "GitHub Dashboard", "private": true, "version": "0.0.2", @@ -16,7 +16,7 @@ "electron-updater": "^6.3.9" }, "devDependencies": { - "@github-dashboard/server": "workspace:*", + "server": "workspace:*", "@types/node": "^25.5.0", "electron": "^32.2.0", "electron-builder": "^25.1.8", diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 54ee00e..6794e78 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -6,9 +6,9 @@ import path from "node:path"; const DEV_WEB_URL = "http://localhost:7200"; -// In dev, `app.name` defaults to the package.json `name` ("@github-dashboard/ -// desktop"), which surfaces as "Electron" in the macOS app menu. Force it -// here so the dev menu matches the packaged build (Info.plist sets it there). +// In dev, `app.name` defaults to the package.json `name` ("desktop"), which +// surfaces as "Electron" in the macOS app menu. Force it here so the dev menu +// matches the packaged build (Info.plist sets it there). app.setName("GitHub Dashboard"); let mainWindow: BrowserWindow | null = null; diff --git a/packages/server/package.json b/packages/server/package.json index 1dc30c5..dfb9369 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,5 +1,5 @@ { - "name": "@github-dashboard/server", + "name": "server", "private": true, "type": "module", "scripts": { diff --git a/packages/web/package.json b/packages/web/package.json index b5fd450..3f2413c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,5 +1,5 @@ { - "name": "@github-dashboard/web", + "name": "web", "private": true, "type": "module", "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba62413..7a451b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,9 +36,6 @@ importers: specifier: ^6.3.9 version: 6.8.3 devDependencies: - '@github-dashboard/server': - specifier: workspace:* - version: link:../server '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -51,6 +48,9 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.12 + server: + specifier: workspace:* + version: link:../server packages/server: dependencies: From 454955581fb0ba21a4c41b0298fafd3239bd2596 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:12:08 +0200 Subject: [PATCH 02/10] sync: scaffold package (manifest, tsconfig, vitest) New `sync` workspace package: a standalone GitHub poller that caches PRs, reviews, and notifications to SQLite. Adds the manifest (octokit, better- sqlite3, yaml, zod; ghd-sync bin + dist exports), tsconfig, and vitest config. Registers better-sqlite3 as a built dependency and updates the lockfile. Source lands in the following commits. --- package.json | 1 + packages/sync/package.json | 33 +++++++ packages/sync/tsconfig.json | 15 +++ packages/sync/vitest.config.ts | 8 ++ pnpm-lock.yaml | 162 +++++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 packages/sync/package.json create mode 100644 packages/sync/tsconfig.json create mode 100644 packages/sync/vitest.config.ts diff --git a/package.json b/package.json index f07eec8..a49f97d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "better-sqlite3", "esbuild", "electron" ] diff --git a/packages/sync/package.json b/packages/sync/package.json new file mode 100644 index 0000000..3757776 --- /dev/null +++ b/packages/sync/package.json @@ -0,0 +1,33 @@ +{ + "name": "sync", + "private": true, + "type": "module", + "bin": { + "ghd-sync": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "dev": "tsx watch src/cli.ts", + "cli": "tsx src/cli.ts", + "build": "tsgo", + "typecheck": "tsgo --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@octokit/rest": "^22.0.1", + "better-sqlite3": "^12.4.1", + "yaml": "^2.8.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.5.0", + "tsx": "^4.19.3", + "vitest": "^4.1.2" + } +} diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json new file mode 100644 index 0000000..1bf2887 --- /dev/null +++ b/packages/sync/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/sync/vitest.config.ts b/packages/sync/vitest.config.ts new file mode 100644 index 0000000..c1433e6 --- /dev/null +++ b/packages/sync/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a451b0..c08222a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,34 @@ importers: specifier: ^4.1.2 version: 4.1.2(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/sync: + dependencies: + '@octokit/rest': + specifier: ^22.0.1 + version: 22.0.1 + better-sqlite3: + specifier: ^12.4.1 + version: 12.10.0 + yaml: + specifier: ^2.8.3 + version: 2.8.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + tsx: + specifier: ^4.19.3 + version: 4.21.0 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/web: dependencies: '@base-ui/react': @@ -1528,6 +1556,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -1846,9 +1877,16 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1967,6 +2005,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -2186,6 +2227,10 @@ packages: babel-plugin-macros: optional: true + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -2432,6 +2477,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2500,6 +2549,9 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -2625,6 +2677,9 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2828,6 +2883,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3481,6 +3539,9 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -3508,6 +3569,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -3744,6 +3808,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -3812,6 +3882,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -4074,6 +4148,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -4190,6 +4270,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -4228,6 +4312,9 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -4319,6 +4406,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -5853,6 +5943,10 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.5.0 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -6216,10 +6310,19 @@ snapshots: before-after-hook@4.0.0: {} + better-sqlite3@12.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -6392,6 +6495,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chownr@1.1.4: {} + chownr@2.0.0: {} chromium-pickle-js@0.2.0: {} @@ -6580,6 +6685,8 @@ snapshots: dedent@1.7.2: {} + deep-extend@0.6.0: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -6920,6 +7027,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -7014,6 +7123,8 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -7150,6 +7261,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7435,6 +7548,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} ip-address@10.1.0: {} @@ -8206,6 +8321,8 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} ms@2.1.3: {} @@ -8239,6 +8356,8 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + negotiator@0.6.4: {} negotiator@1.0.0: {} @@ -8494,6 +8613,21 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -8553,6 +8687,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -8934,6 +9075,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-update-notifier@2.0.0: dependencies: semver: 7.8.0 @@ -9048,6 +9197,8 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -9082,6 +9233,13 @@ snapshots: tapable@2.3.2: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -9175,6 +9333,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} type-fest@0.13.1: From 7186b6204ca7ddc673474f375ad4e49fd2dc246c Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:12:19 +0200 Subject: [PATCH 03/10] =?UTF-8?q?sync(cache):=20SQLite=20cache=20file=20?= =?UTF-8?q?=E2=80=94=20open,=20schema,=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache-file plumbing: resolveCachePath (XDG-aware default location), the schema DDL + CACHE_SCHEMA_VERSION, and openCache, which opens the better-sqlite3 handle, applies the schema, and wipes+recreates on a version mismatch. wipeCacheFile removes the file outright. --- packages/sync/src/cache/open.test.ts | 84 ++++++++++++++++++++++++++++ packages/sync/src/cache/open.ts | 63 +++++++++++++++++++++ packages/sync/src/cache/path.ts | 18 ++++++ packages/sync/src/cache/schema.ts | 73 ++++++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 packages/sync/src/cache/open.test.ts create mode 100644 packages/sync/src/cache/open.ts create mode 100644 packages/sync/src/cache/path.ts create mode 100644 packages/sync/src/cache/schema.ts diff --git a/packages/sync/src/cache/open.test.ts b/packages/sync/src/cache/open.test.ts new file mode 100644 index 0000000..5c0a007 --- /dev/null +++ b/packages/sync/src/cache/open.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { openCache, wipeCacheFile } from "./open.js"; +import { CACHE_SCHEMA_VERSION } from "./schema.js"; + +describe("cache open", () => { + let cacheRoot: string; + let prevXdg: string | undefined; + + beforeEach(() => { + cacheRoot = mkdtempSync(join(tmpdir(), "ghd-cache-")); + prevXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = cacheRoot; + }); + + afterEach(() => { + if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevXdg; + rmSync(cacheRoot, { recursive: true, force: true }); + }); + + test("creates file with current schema version on first open", () => { + const { db, path, wiped } = openCache(); + expect(wiped).toBe(false); + expect(path).toContain("github-dashboard/cache.sqlite"); + const row = db + .prepare("SELECT value FROM meta WHERE key = 'schema_version'") + .get() as { value: string }; + expect(Number(row.value)).toBe(CACHE_SCHEMA_VERSION); + db.close(); + }); + + test("reuses existing file when version matches", () => { + const first = openCache(); + first.db + .prepare( + "INSERT INTO instances (id, label, base_url, username) VALUES ('x', 'X', 'https://api.github.com', 'u')", + ) + .run(); + first.db.close(); + + const second = openCache(); + expect(second.wiped).toBe(false); + const count = ( + second.db.prepare("SELECT COUNT(*) AS n FROM instances").get() as { + n: number; + } + ).n; + expect(count).toBe(1); + second.db.close(); + }); + + test("wipes and recreates when stored version doesn't match code version", () => { + const first = openCache(); + first.db + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run("999"); + first.db + .prepare( + "INSERT INTO instances (id, label, base_url, username) VALUES ('x', 'X', 'https://api.github.com', 'u')", + ) + .run(); + first.db.close(); + + const second = openCache(); + expect(second.wiped).toBe(true); + const count = ( + second.db.prepare("SELECT COUNT(*) AS n FROM instances").get() as { + n: number; + } + ).n; + expect(count).toBe(0); + second.db.close(); + }); + + test("wipeCacheFile removes the file", () => { + const { db } = openCache(); + db.close(); + const result = wipeCacheFile(); + expect(result.existed).toBe(true); + }); +}); diff --git a/packages/sync/src/cache/open.ts b/packages/sync/src/cache/open.ts new file mode 100644 index 0000000..10cb870 --- /dev/null +++ b/packages/sync/src/cache/open.ts @@ -0,0 +1,63 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname } from "node:path"; +import Database from "better-sqlite3"; +import { resolveCachePath } from "./path.js"; +import { CACHE_SCHEMA_VERSION, SCHEMA_DDL } from "./schema.js"; + +export type Cache = Database.Database; + +export interface OpenCacheResult { + db: Cache; + path: string; + wiped: boolean; +} + +export function openCache(): OpenCacheResult { + const path = resolveCachePath(); + mkdirSync(dirname(path), { recursive: true }); + + let db = new Database(path); + db.exec(SCHEMA_DDL); + + const stored = readSchemaVersion(db); + if (stored !== null && stored !== CACHE_SCHEMA_VERSION) { + db.close(); + deleteCacheFiles(path); + db = new Database(path); + db.exec(SCHEMA_DDL); + writeSchemaVersion(db, CACHE_SCHEMA_VERSION); + return { db, path, wiped: true }; + } + if (stored === null) { + writeSchemaVersion(db, CACHE_SCHEMA_VERSION); + } + return { db, path, wiped: false }; +} + +function readSchemaVersion(db: Cache): number | null { + const row = db + .prepare("SELECT value FROM meta WHERE key = 'schema_version'") + .get() as { value: string } | undefined; + if (!row) return null; + const n = Number.parseInt(row.value, 10); + return Number.isFinite(n) ? n : null; +} + +function writeSchemaVersion(db: Cache, version: number): void { + db.prepare( + "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", + ).run(String(version)); +} + +export function wipeCacheFile(): { existed: boolean; path: string } { + const path = resolveCachePath(); + const existed = existsSync(path); + deleteCacheFiles(path); + return { existed, path }; +} + +function deleteCacheFiles(path: string): void { + for (const p of [path, `${path}-wal`, `${path}-shm`]) { + if (existsSync(p)) rmSync(p, { force: true }); + } +} diff --git a/packages/sync/src/cache/path.ts b/packages/sync/src/cache/path.ts new file mode 100644 index 0000000..5bffbd4 --- /dev/null +++ b/packages/sync/src/cache/path.ts @@ -0,0 +1,18 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function resolveCachePath(): string { + const xdg = process.env.XDG_CACHE_HOME?.trim(); + const base = + xdg && xdg.length > 0 + ? xdg + : process.env.HOME + ? join(homedir(), ".cache") + : null; + if (!base) { + throw new Error( + "cannot resolve cache path: neither $XDG_CACHE_HOME nor $HOME is set", + ); + } + return join(base, "github-dashboard", "cache.sqlite"); +} diff --git a/packages/sync/src/cache/schema.ts b/packages/sync/src/cache/schema.ts new file mode 100644 index 0000000..403ea14 --- /dev/null +++ b/packages/sync/src/cache/schema.ts @@ -0,0 +1,73 @@ +export const CACHE_SCHEMA_VERSION = 1; + +export const SCHEMA_DDL = ` +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS instances ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + base_url TEXT NOT NULL, + username TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS prs ( + instance_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('authored', 'review_requested')), + provider_ref TEXT NOT NULL, + number INTEGER NOT NULL, + repo TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT NOT NULL, + draft INTEGER NOT NULL, + ci_status TEXT NOT NULL, + in_merge_queue INTEGER NOT NULL, + auto_merge INTEGER NOT NULL, + unresolved_threads INTEGER NOT NULL, + additions INTEGER NOT NULL, + deletions INTEGER NOT NULL, + commits INTEGER NOT NULL, + comment_count INTEGER NOT NULL, + mergeable TEXT, + updated_at TEXT NOT NULL, + payload TEXT NOT NULL CHECK (json_valid(payload)), + PRIMARY KEY (instance_id, kind, provider_ref), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_prs_instance_kind ON prs(instance_id, kind); +CREATE INDEX IF NOT EXISTS idx_prs_updated ON prs(updated_at); + +CREATE TABLE IF NOT EXISTS notifications ( + instance_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL, + type TEXT, + reason TEXT NOT NULL, + repo TEXT NOT NULL, + url TEXT NOT NULL, + unread INTEGER NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (instance_id, id), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_notifications_instance ON notifications(instance_id); + +CREATE TABLE IF NOT EXISTS sync_state ( + instance_id TEXT NOT NULL, + kind TEXT NOT NULL, + last_run_at TEXT, + last_etag TEXT, + last_modified TEXT, + rate_remaining INTEGER, + rate_reset_at TEXT, + PRIMARY KEY (instance_id, kind), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); +`; From 103fa191f249c8b3da80fd8ef8d889b5303642e3 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:12:29 +0200 Subject: [PATCH 04/10] sync(cache): store repository over the cache The Repository contract and its SQLite implementation: upserting instances, replacing PR/notification rows per instance+kind, reading back summaries, and tracking per-resource sync state (ETag/last-fetched). Built on the Cache handle from openCache. --- packages/sync/src/cache/store.test.ts | 204 ++++++++++++++++++++++ packages/sync/src/cache/store.ts | 235 ++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 packages/sync/src/cache/store.test.ts create mode 100644 packages/sync/src/cache/store.ts diff --git a/packages/sync/src/cache/store.test.ts b/packages/sync/src/cache/store.test.ts new file mode 100644 index 0000000..518c55c --- /dev/null +++ b/packages/sync/src/cache/store.test.ts @@ -0,0 +1,204 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { openCache } from "./open.js"; +import { type PrRow, createSqliteRepository } from "./store.js"; + +function openTestRepo() { + const { db, path } = openCache(); + return { repo: createSqliteRepository(db), path, close: () => db.close() }; +} + +describe("createSqliteRepository", () => { + let cacheRoot: string; + let prevXdg: string | undefined; + + beforeEach(() => { + cacheRoot = mkdtempSync(join(tmpdir(), "ghd-repo-")); + prevXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = cacheRoot; + }); + + afterEach(() => { + if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevXdg; + rmSync(cacheRoot, { recursive: true, force: true }); + }); + + test("upsert + list + delete instance round-trips", () => { + const { repo, close } = openTestRepo(); + try { + repo.upsertInstance({ + id: "a", + label: "A", + baseUrl: "https://api.github.com", + username: "u1", + }); + repo.upsertInstance({ + id: "b", + label: "B", + baseUrl: "https://api.github.com", + username: "u2", + }); + expect(repo.listInstanceIds()).toEqual(["a", "b"]); + + repo.upsertInstance({ + id: "a", + label: "A-renamed", + baseUrl: "https://api.github.com", + username: "u1", + }); + expect(repo.listInstances().find((i) => i.id === "a")?.label).toBe( + "A-renamed", + ); + + repo.deleteInstance("a"); + expect(repo.listInstanceIds()).toEqual(["b"]); + } finally { + close(); + } + }); + + test("deleting an instance cascades PRs and notifications", () => { + const { repo, close } = openTestRepo(); + try { + repo.upsertInstance({ + id: "x", + label: "X", + baseUrl: "https://api.github.com", + username: "u", + }); + const pr: PrRow = { + instance_id: "x", + kind: "authored", + provider_ref: "PR_1", + number: 1, + repo: "o/r", + title: "t", + author: "u", + draft: 0, + ci_status: "success", + in_merge_queue: 0, + auto_merge: 0, + unresolved_threads: 0, + additions: 1, + deletions: 0, + commits: 1, + comment_count: 0, + mergeable: "MERGEABLE", + updated_at: "2026-01-01T00:00:00Z", + payload: '{"id":1,"number":1}', + }; + repo.replacePrs("x", "authored", [pr]); + repo.replaceNotifications("x", [ + { + instance_id: "x", + id: "n1", + title: "hi", + type: "PullRequest", + reason: "mention", + repo: "o/r", + url: "https://github.com/o/r/pull/1", + unread: 1, + updated_at: "2026-01-01T00:00:00Z", + }, + ]); + expect(repo.countPrsByKind("x")).toEqual([ + { kind: "authored", count: 1 }, + ]); + expect(repo.countNotifications("x")).toBe(1); + expect(repo.listNotifications("x")[0]?.title).toBe("hi"); + + repo.deleteInstance("x"); + expect(repo.countPrsByKind("x")).toEqual([]); + expect(repo.countNotifications("x")).toBe(0); + expect(repo.listNotifications("x")).toEqual([]); + } finally { + close(); + } + }); + + test("replacePrs is atomic — old rows replaced wholesale by new set", () => { + const { repo, close } = openTestRepo(); + try { + repo.upsertInstance({ + id: "x", + label: "X", + baseUrl: "https://api.github.com", + username: "u", + }); + const make = (n: number): PrRow => ({ + instance_id: "x", + kind: "authored", + provider_ref: `PR_${n}`, + number: n, + repo: "o/r", + title: `pr ${n}`, + author: "u", + draft: 0, + ci_status: "success", + in_merge_queue: 0, + auto_merge: 0, + unresolved_threads: 0, + additions: 1, + deletions: 0, + commits: 1, + comment_count: 0, + mergeable: "MERGEABLE", + updated_at: `2026-01-0${n}T00:00:00Z`, + payload: `{"id":${n},"number":${n}}`, + }); + repo.replacePrs("x", "authored", [make(1), make(2), make(3)]); + expect(repo.countPrsByKind("x")).toEqual([ + { kind: "authored", count: 3 }, + ]); + + repo.replacePrs("x", "authored", [make(4)]); + expect(repo.countPrsByKind("x")).toEqual([ + { kind: "authored", count: 1 }, + ]); + const payloads = repo.getPrPayloads("x", "authored") as { + number: number; + }[]; + expect(payloads.map((p) => p.number)).toEqual([4]); + } finally { + close(); + } + }); + + test("sync state upsert preserves last_etag when next call passes null", () => { + const { repo, close } = openTestRepo(); + try { + repo.upsertInstance({ + id: "x", + label: "X", + baseUrl: "https://api.github.com", + username: "u", + }); + repo.upsertSyncState({ + instance_id: "x", + kind: "notifications", + last_run_at: "2026-01-01T00:00:00Z", + last_etag: '"abc"', + last_modified: "Wed, 01 Jan 2026 00:00:00 GMT", + rate_remaining: 4900, + rate_reset_at: "2026-01-01T01:00:00Z", + }); + repo.upsertSyncState({ + instance_id: "x", + kind: "notifications", + last_run_at: "2026-01-01T00:00:30Z", + last_etag: null, + last_modified: null, + rate_remaining: null, + rate_reset_at: null, + }); + const state = repo.getSyncState("x", "notifications"); + expect(state?.last_etag).toBe('"abc"'); + expect(state?.last_run_at).toBe("2026-01-01T00:00:30Z"); + } finally { + close(); + } + }); +}); diff --git a/packages/sync/src/cache/store.ts b/packages/sync/src/cache/store.ts new file mode 100644 index 0000000..538dbf0 --- /dev/null +++ b/packages/sync/src/cache/store.ts @@ -0,0 +1,235 @@ +import type { Cache } from "./open.js"; + +export interface InstanceRow { + id: string; + label: string; + baseUrl: string; + username: string; +} + +export interface InstanceSummary { + id: string; + label: string; + username: string; +} + +export type PrKind = "authored" | "review_requested"; + +export interface PrRow { + instance_id: string; + kind: PrKind; + provider_ref: string; + number: number; + repo: string; + title: string; + author: string; + draft: number; + ci_status: string; + in_merge_queue: number; + auto_merge: number; + unresolved_threads: number; + additions: number; + deletions: number; + commits: number; + comment_count: number; + mergeable: string | null; + updated_at: string; + payload: string; +} + +export interface NotificationRow { + instance_id: string; + id: string; + title: string; + type: string | null; + reason: string; + repo: string; + url: string; + unread: number; + updated_at: string; +} + +export interface SyncStateRow { + instance_id: string; + kind: string; + last_run_at: string | null; + last_etag: string | null; + last_modified: string | null; + rate_remaining: number | null; + rate_reset_at: string | null; +} + +export interface PrKindCount { + kind: PrKind; + count: number; +} + +// Repository is the contract the rest of the engine depends on. Providers, +// the sync orchestrator, and the CLI take a Repository rather than a raw +// SQLite handle — keeps the SQL inside one module and makes swapping in a +// fake for tests a one-liner. +export interface Repository { + // instances + listInstances(): InstanceSummary[]; + listInstanceIds(): string[]; + upsertInstance(row: InstanceRow): void; + deleteInstance(id: string): void; + + // prs + replacePrs(instanceId: string, kind: PrKind, rows: PrRow[]): void; + getPrPayloads(instanceId: string, kind: PrKind): unknown[]; + countPrsByKind(instanceId: string): PrKindCount[]; + + // notifications + replaceNotifications(instanceId: string, rows: NotificationRow[]): void; + listNotifications(instanceId: string): NotificationRow[]; + countNotifications(instanceId: string): number; + + // sync state + getSyncState(instanceId: string, kind: string): SyncStateRow | null; + listSyncStates(instanceId: string): SyncStateRow[]; + upsertSyncState(row: SyncStateRow): void; + + // meta + getSchemaVersion(): number | null; +} + +export function createSqliteRepository(db: Cache): Repository { + const stmts = { + listInstances: db.prepare( + "SELECT id, label, username FROM instances ORDER BY id", + ), + listInstanceIds: db.prepare("SELECT id FROM instances ORDER BY id"), + upsertInstance: db.prepare( + `INSERT INTO instances (id, label, base_url, username) + VALUES (@id, @label, @baseUrl, @username) + ON CONFLICT(id) DO UPDATE SET + label = excluded.label, + base_url = excluded.base_url, + username = excluded.username`, + ), + deleteInstance: db.prepare("DELETE FROM instances WHERE id = ?"), + + deletePrsByKind: db.prepare( + "DELETE FROM prs WHERE instance_id = ? AND kind = ?", + ), + insertPr: db.prepare( + `INSERT INTO prs ( + instance_id, kind, provider_ref, number, repo, title, author, draft, + ci_status, in_merge_queue, auto_merge, unresolved_threads, + additions, deletions, commits, comment_count, mergeable, updated_at, payload + ) VALUES ( + @instance_id, @kind, @provider_ref, @number, @repo, @title, @author, @draft, + @ci_status, @in_merge_queue, @auto_merge, @unresolved_threads, + @additions, @deletions, @commits, @comment_count, @mergeable, @updated_at, @payload + )`, + ), + selectPrPayloads: db.prepare( + "SELECT payload FROM prs WHERE instance_id = ? AND kind = ? ORDER BY updated_at DESC", + ), + countPrsByKind: db.prepare( + "SELECT kind, COUNT(*) AS count FROM prs WHERE instance_id = ? GROUP BY kind", + ), + + deleteNotifications: db.prepare( + "DELETE FROM notifications WHERE instance_id = ?", + ), + insertNotification: db.prepare( + `INSERT INTO notifications ( + instance_id, id, title, type, reason, repo, url, unread, updated_at + ) VALUES ( + @instance_id, @id, @title, @type, @reason, @repo, @url, @unread, @updated_at + )`, + ), + listNotifications: db.prepare( + "SELECT * FROM notifications WHERE instance_id = ? ORDER BY updated_at DESC", + ), + countNotifications: db.prepare( + "SELECT COUNT(*) AS n FROM notifications WHERE instance_id = ?", + ), + + getSyncState: db.prepare( + "SELECT * FROM sync_state WHERE instance_id = ? AND kind = ?", + ), + listSyncStates: db.prepare( + "SELECT * FROM sync_state WHERE instance_id = ? ORDER BY kind", + ), + upsertSyncState: db.prepare( + `INSERT INTO sync_state ( + instance_id, kind, last_run_at, last_etag, last_modified, rate_remaining, rate_reset_at + ) VALUES ( + @instance_id, @kind, @last_run_at, @last_etag, @last_modified, @rate_remaining, @rate_reset_at + ) + ON CONFLICT(instance_id, kind) DO UPDATE SET + last_run_at = excluded.last_run_at, + last_etag = COALESCE(excluded.last_etag, sync_state.last_etag), + last_modified = COALESCE(excluded.last_modified, sync_state.last_modified), + rate_remaining = excluded.rate_remaining, + rate_reset_at = excluded.rate_reset_at`, + ), + + getSchemaVersion: db.prepare( + "SELECT value FROM meta WHERE key = 'schema_version'", + ), + }; + + const replacePrsTx = db.transaction( + (instanceId: string, kind: PrKind, rows: PrRow[]) => { + stmts.deletePrsByKind.run(instanceId, kind); + for (const row of rows) stmts.insertPr.run(row); + }, + ); + + const replaceNotificationsTx = db.transaction( + (instanceId: string, rows: NotificationRow[]) => { + stmts.deleteNotifications.run(instanceId); + for (const row of rows) stmts.insertNotification.run(row); + }, + ); + + return { + listInstances: () => stmts.listInstances.all() as InstanceSummary[], + listInstanceIds: () => + (stmts.listInstanceIds.all() as { id: string }[]).map((r) => r.id), + upsertInstance: (row) => { + stmts.upsertInstance.run(row); + }, + deleteInstance: (id) => { + stmts.deleteInstance.run(id); + }, + + replacePrs: (instanceId, kind, rows) => { + replacePrsTx(instanceId, kind, rows); + }, + getPrPayloads: (instanceId, kind) => + ( + stmts.selectPrPayloads.all(instanceId, kind) as { payload: string }[] + ).map((r) => JSON.parse(r.payload)), + countPrsByKind: (instanceId) => + stmts.countPrsByKind.all(instanceId) as PrKindCount[], + + replaceNotifications: (instanceId, rows) => { + replaceNotificationsTx(instanceId, rows); + }, + listNotifications: (instanceId) => + stmts.listNotifications.all(instanceId) as NotificationRow[], + countNotifications: (instanceId) => + (stmts.countNotifications.get(instanceId) as { n: number }).n, + + getSyncState: (instanceId, kind) => + (stmts.getSyncState.get(instanceId, kind) as SyncStateRow | undefined) ?? + null, + listSyncStates: (instanceId) => + stmts.listSyncStates.all(instanceId) as SyncStateRow[], + upsertSyncState: (row) => { + stmts.upsertSyncState.run(row); + }, + + getSchemaVersion: () => { + const row = stmts.getSchemaVersion.get() as { value: string } | undefined; + if (!row) return null; + const n = Number.parseInt(row.value, 10); + return Number.isFinite(n) ? n : null; + }, + }; +} From fc5ea97865c74eaa73375395f22c582ea0d2dbc8 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:12:37 +0200 Subject: [PATCH 05/10] sync(config): load instances from config.yml loadInstances reads ~/.config/github-dashboard/config.yml, validates it with zod, and derives a stable instance id per host. resolveConfigPath and instanceIdFromDomain are exported for reuse. --- packages/sync/src/config.ts | 106 ++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 packages/sync/src/config.ts diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts new file mode 100644 index 0000000..a945ba5 --- /dev/null +++ b/packages/sync/src/config.ts @@ -0,0 +1,106 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { Octokit } from "@octokit/rest"; +import { parse } from "yaml"; +import { z } from "zod"; + +export interface GitHubInstance { + id: string; + label: string; + baseUrl: string; + token: string; + username: string; +} + +export function instanceIdFromDomain(domain: string): string { + return domain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +const instanceSchemaZ = z.object({ + domain: z + .string() + .min(1) + .refine((d) => instanceIdFromDomain(d).length > 0, { + message: "domain must be a hostname (e.g. github.com)", + }), + token: z.string().min(1), + label: z.string().optional(), +}); + +const configSchemaZ = z.object({ + theme: z.enum(["system", "light", "dark"]).optional(), + instances: z.array(instanceSchemaZ).optional(), +}); + +export function resolveConfigPath(): string { + const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); + return join(base, "github-dashboard", "config.yml"); +} + +function domainToApiBase(domain: string): string { + const trimmed = domain.trim(); + const withScheme = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + const url = new URL(withScheme); + const host = url.host.toLowerCase(); + if (host === "github.com" || host === "www.github.com") { + return "https://api.github.com"; + } + return `${url.origin}/api/v3`; +} + +// The yaml parser echoes the offending source line in `err.message`, which +// can include a token if the malformed line is `token: ghp_...`. Strip it +// down to position info only. +function sanitizeYamlError(err: unknown): string { + const e = err as { linePos?: Array<{ line: number; col: number }> }; + const pos = e.linePos?.[0]; + if (pos) { + return `YAML parse error at line ${pos.line}, column ${pos.col}`; + } + return "Invalid YAML syntax"; +} + +export async function loadInstances(): Promise { + const path = resolveConfigPath(); + if (!existsSync(path)) { + throw new Error(`config not found at ${path}`); + } + + let raw: unknown; + try { + raw = parse(readFileSync(path, "utf-8")); + } catch (err) { + throw new Error(sanitizeYamlError(err)); + } + + const parsed = configSchemaZ.parse(raw); + const entries = parsed.instances ?? []; + if (entries.length === 0) { + throw new Error(`no instances configured in ${path}`); + } + + const instances: GitHubInstance[] = []; + for (const entry of entries) { + const id = instanceIdFromDomain(entry.domain); + const baseUrl = domainToApiBase(entry.domain); + const client = new Octokit({ auth: entry.token, baseUrl }); + const { data } = await client.users.getAuthenticated(); + instances.push({ + id, + label: entry.label || entry.domain, + baseUrl, + token: entry.token, + username: data.login, + }); + } + return instances; +} From 09d5dcd8a09f2dca7de6d7eed16ce8eeabf75726 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:12:49 +0200 Subject: [PATCH 06/10] sync(github): provider fetchers for PRs, reviews, notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub provider: a per-(id,token,baseUrl) Octokit client cache (so token rotation or baseUrl change gets a fresh client), GraphQL queries + normalization into PrRow/NotificationRow, and the three fetchers — authored PRs, review-requested PRs, and notifications. commentCount sums issue comments plus review-thread comments to match the server payload; notification paging stops early when the first page is short to spare the REST rate limit. --- packages/sync/src/providers/github/client.ts | 34 +++ .../providers/github/fetchNotifications.ts | 144 +++++++++++ .../sync/src/providers/github/fetchPrs.ts | 67 +++++ .../sync/src/providers/github/fetchReviews.ts | 132 ++++++++++ .../sync/src/providers/github/normalize.ts | 124 ++++++++++ .../src/providers/github/notificationUrl.ts | 68 ++++++ packages/sync/src/providers/github/queries.ts | 229 ++++++++++++++++++ 7 files changed, 798 insertions(+) create mode 100644 packages/sync/src/providers/github/client.ts create mode 100644 packages/sync/src/providers/github/fetchNotifications.ts create mode 100644 packages/sync/src/providers/github/fetchPrs.ts create mode 100644 packages/sync/src/providers/github/fetchReviews.ts create mode 100644 packages/sync/src/providers/github/normalize.ts create mode 100644 packages/sync/src/providers/github/notificationUrl.ts create mode 100644 packages/sync/src/providers/github/queries.ts diff --git a/packages/sync/src/providers/github/client.ts b/packages/sync/src/providers/github/client.ts new file mode 100644 index 0000000..fb8eb77 --- /dev/null +++ b/packages/sync/src/providers/github/client.ts @@ -0,0 +1,34 @@ +import { Octokit } from "@octokit/rest"; +import type { GitHubInstance } from "../../config.js"; + +interface CachedClient { + client: Octokit; + token: string; + baseUrl: string; +} + +const clients = new Map(); + +export function getClient(instance: GitHubInstance): Octokit { + // Reuse the cached client only while credentials are unchanged. A token + // rotation or baseUrl switch (e.g. config edit while looping) recreates it, + // so we never keep authenticating with a stale token until restart. + const existing = clients.get(instance.id); + if ( + existing && + existing.token === instance.token && + existing.baseUrl === instance.baseUrl + ) { + return existing.client; + } + const client = new Octokit({ + auth: instance.token, + baseUrl: instance.baseUrl, + }); + clients.set(instance.id, { + client, + token: instance.token, + baseUrl: instance.baseUrl, + }); + return client; +} diff --git a/packages/sync/src/providers/github/fetchNotifications.ts b/packages/sync/src/providers/github/fetchNotifications.ts new file mode 100644 index 0000000..4f21b73 --- /dev/null +++ b/packages/sync/src/providers/github/fetchNotifications.ts @@ -0,0 +1,144 @@ +import type { NotificationRow, Repository } from "../../cache/store.js"; +import type { GitHubInstance } from "../../config.js"; +import { getClient } from "./client.js"; +import { notificationHtmlUrl } from "./notificationUrl.js"; + +export interface FetchNotificationsResult { + count: number; + notModified: boolean; + rateRemaining: number | null; +} + +const PAGES = 3; +const PER_PAGE = 50; + +type Notification = { + id: string; + reason: string; + unread: boolean; + updated_at: string; + subject: { + title: string; + type: string | null; + url: string | null; + latest_comment_url: string | null; + }; + repository: { full_name: string }; +}; + +const redundantRules: ReadonlyArray<(n: Notification) => boolean> = [ + (n) => n.reason === "review_requested", + (n) => n.reason === "ci_activity", + (n) => n.subject.type === "PullRequest" && n.reason === "author", + (n) => n.subject.type === "PullRequest" && n.reason === "state_change", + (n) => n.reason === "subscribed", +]; + +function isRedundant(n: Notification): boolean { + return redundantRules.some((rule) => rule(n)); +} + +export async function fetchNotifications( + repo: Repository, + instance: GitHubInstance, +): Promise { + const client = getClient(instance); + const state = repo.getSyncState(instance.id, "notifications"); + const ifNoneMatch = state?.last_etag ?? null; + + let firstPageResp: { data: Notification[]; headers: Record }; + try { + firstPageResp = + (await client.activity.listNotificationsForAuthenticatedUser({ + all: true, + per_page: PER_PAGE, + page: 1, + headers: ifNoneMatch ? { "if-none-match": ifNoneMatch } : {}, + })) as unknown as { + data: Notification[]; + headers: Record; + }; + } catch (err) { + const e = err as { status?: number }; + if (e.status === 304) { + repo.upsertSyncState({ + instance_id: instance.id, + kind: "notifications", + last_run_at: new Date().toISOString(), + last_etag: null, + last_modified: null, + rate_remaining: null, + rate_reset_at: null, + }); + return { count: 0, notModified: true, rateRemaining: null }; + } + throw err; + } + + // A short first page means there are no further pages — skip the extra + // REST calls (which are charged against the rate limit, unlike 304s). + const remainingPages = + firstPageResp.data.length < PER_PAGE + ? [] + : await Promise.all( + Array.from({ length: PAGES - 1 }, (_, i) => + client.activity.listNotificationsForAuthenticatedUser({ + all: true, + per_page: PER_PAGE, + page: i + 2, + }), + ), + ); + + const all: Notification[] = [ + ...firstPageResp.data, + ...remainingPages.flatMap((r) => r.data as unknown as Notification[]), + ]; + + const rows: NotificationRow[] = all + .filter((n) => !isRedundant(n)) + .map((n) => ({ + instance_id: instance.id, + id: n.id, + title: n.subject.title, + type: n.subject.type, + reason: n.reason, + repo: n.repository.full_name, + url: notificationHtmlUrl( + n.subject.url, + n.subject.type, + n.repository.full_name, + instance.baseUrl, + n.subject.latest_comment_url, + ), + unread: n.unread ? 1 : 0, + updated_at: n.updated_at, + })); + + repo.replaceNotifications(instance.id, rows); + + const rateRemainingRaw = firstPageResp.headers["x-ratelimit-remaining"]; + const rateRemaining = rateRemainingRaw + ? Number.parseInt(rateRemainingRaw, 10) + : null; + const rateResetRaw = firstPageResp.headers["x-ratelimit-reset"]; + const rateResetAt = rateResetRaw + ? new Date(Number.parseInt(rateResetRaw, 10) * 1000).toISOString() + : null; + + repo.upsertSyncState({ + instance_id: instance.id, + kind: "notifications", + last_run_at: new Date().toISOString(), + last_etag: firstPageResp.headers.etag ?? null, + last_modified: firstPageResp.headers["last-modified"] ?? null, + rate_remaining: rateRemaining, + rate_reset_at: rateResetAt, + }); + + return { + count: rows.length, + notModified: false, + rateRemaining, + }; +} diff --git a/packages/sync/src/providers/github/fetchPrs.ts b/packages/sync/src/providers/github/fetchPrs.ts new file mode 100644 index 0000000..9dc717a --- /dev/null +++ b/packages/sync/src/providers/github/fetchPrs.ts @@ -0,0 +1,67 @@ +import type { PrRow, Repository } from "../../cache/store.js"; +import type { GitHubInstance } from "../../config.js"; +import { getClient } from "./client.js"; +import { normalizePr } from "./normalize.js"; +import { type PrNode, SEARCH_PRS, type SearchPrsResponse } from "./queries.js"; + +export interface FetchPrsResult { + count: number; + rateRemaining: number; + rateResetAt: string; +} + +export async function fetchAuthoredPrs( + repo: Repository, + instance: GitHubInstance, +): Promise { + const client = getClient(instance); + const data = await client.graphql(SEARCH_PRS, { + q: `author:${instance.username} type:pr state:open`, + first: 100, + }); + const kind = "authored"; + + const nodes = data.search.nodes.filter((n): n is PrNode => n != null); + const normalized = nodes.map(normalizePr); + + const rows: PrRow[] = normalized.map((pr) => ({ + instance_id: instance.id, + kind, + provider_ref: String(pr.id), + number: pr.number, + repo: pr.repo, + title: pr.title, + author: pr.author, + draft: pr.draft ? 1 : 0, + ci_status: pr.ciStatus, + in_merge_queue: pr.inMergeQueue ? 1 : 0, + auto_merge: pr.autoMerge ? 1 : 0, + unresolved_threads: pr.unresolvedThreadCount, + additions: pr.additions, + deletions: pr.deletions, + commits: pr.commits, + comment_count: pr.commentCount, + mergeable: + pr.mergeable === null ? null : pr.mergeable ? "MERGEABLE" : "CONFLICTING", + updated_at: pr.updatedAt, + payload: JSON.stringify(pr), + })); + + repo.replacePrs(instance.id, kind, rows); + + repo.upsertSyncState({ + instance_id: instance.id, + kind, + last_run_at: new Date().toISOString(), + last_etag: null, + last_modified: null, + rate_remaining: data.rateLimit.remaining, + rate_reset_at: data.rateLimit.resetAt, + }); + + return { + count: rows.length, + rateRemaining: data.rateLimit.remaining, + rateResetAt: data.rateLimit.resetAt, + }; +} diff --git a/packages/sync/src/providers/github/fetchReviews.ts b/packages/sync/src/providers/github/fetchReviews.ts new file mode 100644 index 0000000..b1734b2 --- /dev/null +++ b/packages/sync/src/providers/github/fetchReviews.ts @@ -0,0 +1,132 @@ +import type { PrRow, Repository } from "../../cache/store.js"; +import type { GitHubInstance } from "../../config.js"; +import { getClient } from "./client.js"; +import { normalizePr } from "./normalize.js"; +import { + type ReviewPrNode, + SEARCH_REVIEWS, + type SearchReviewsResponse, + type TimelineEventNode, +} from "./queries.js"; + +export interface FetchReviewsResult { + count: number; + rateRemaining: number; + rateResetAt: string; +} + +export async function fetchReviews( + repo: Repository, + instance: GitHubInstance, +): Promise { + const client = getClient(instance); + const data = await client.graphql(SEARCH_REVIEWS, { + q: `review-requested:${instance.username} type:pr state:open`, + first: 100, + }); + + const nodes = data.search.nodes.filter((n): n is ReviewPrNode => n != null); + + const rows: PrRow[] = nodes.map((node) => { + const pr = normalizePr(node); + const autoAssigned = detectAutoAssigned(node, instance.username); + const payload = { ...pr, autoAssigned }; + return { + instance_id: instance.id, + kind: "review_requested", + provider_ref: String(pr.id), + number: pr.number, + repo: pr.repo, + title: pr.title, + author: pr.author, + draft: pr.draft ? 1 : 0, + ci_status: pr.ciStatus, + in_merge_queue: pr.inMergeQueue ? 1 : 0, + auto_merge: pr.autoMerge ? 1 : 0, + unresolved_threads: pr.unresolvedThreadCount, + additions: pr.additions, + deletions: pr.deletions, + commits: pr.commits, + comment_count: pr.commentCount, + mergeable: + pr.mergeable === null + ? null + : pr.mergeable + ? "MERGEABLE" + : "CONFLICTING", + updated_at: pr.updatedAt, + payload: JSON.stringify(payload), + }; + }); + + repo.replacePrs(instance.id, "review_requested", rows); + + repo.upsertSyncState({ + instance_id: instance.id, + kind: "review_requested", + last_run_at: new Date().toISOString(), + last_etag: null, + last_modified: null, + rate_remaining: data.rateLimit.remaining, + rate_reset_at: data.rateLimit.resetAt, + }); + + return { + count: rows.length, + rateRemaining: data.rateLimit.remaining, + rateResetAt: data.rateLimit.resetAt, + }; +} + +// Auto-assigned heuristic. Auto when: +// - The actor is a bot (__typename === "Bot" or login ends in `[bot]`), or +// - The actor is the PR author AND the event fired within 2s of PR creation. +// We track the *current* attachment per user: the most recent review_requested +// event not superseded by a review_request_removed. When the user isn't in the +// direct requested-reviewer list (team-based attachment), fall back to: any +// auto-actor event present in the timeline. The team identity is intentionally +// not used — GraphQL's Team.slug requires read:org scope, which most tokens +// don't have, so we accept a coarser heuristic for team-based cases. +function detectAutoAssigned(node: ReviewPrNode, username: string): boolean { + const prAuthor = node.author?.login; + const prCreatedMs = node.createdAt ? Date.parse(node.createdAt) : null; + const inRequestedReviewers = node.reviewRequests.nodes.some( + (r) => r.requestedReviewer?.login === username, + ); + + const isAutoActor = (ev: TimelineEventNode | undefined): boolean => { + if (!ev?.actor) return false; + const actorLogin = ev.actor.login; + if (ev.actor.__typename === "Bot" || actorLogin.endsWith("[bot]")) { + return true; + } + if (!prAuthor || actorLogin !== prAuthor) return false; + if (prCreatedMs == null) return false; + return Math.abs(Date.parse(ev.createdAt) - prCreatedMs) <= 2000; + }; + + if (inRequestedReviewers) { + const currentReviewerAttachment = new Map(); + for (const ev of node.timelineItems.nodes) { + const reviewer = ev.requestedReviewer?.login; + if (!reviewer) continue; + if (ev.__typename === "ReviewRequestedEvent") { + currentReviewerAttachment.set(reviewer, ev); + } else if (ev.__typename === "ReviewRequestRemovedEvent") { + currentReviewerAttachment.delete(reviewer); + } + } + return isAutoActor(currentReviewerAttachment.get(username)); + } + + // Team-based: we can't disambiguate which team this PR was attached via, so + // approximate: any auto-actor ReviewRequestedEvent in the timeline suggests + // the team attachment was automatic. We intentionally don't track removals + // here — it's a coarse heuristic for the team case. + const teamLikeEvents = node.timelineItems.nodes.filter( + (ev) => !ev.requestedReviewer?.login, + ); + return teamLikeEvents.some( + (ev) => ev.__typename === "ReviewRequestedEvent" && isAutoActor(ev), + ); +} diff --git a/packages/sync/src/providers/github/normalize.ts b/packages/sync/src/providers/github/normalize.ts new file mode 100644 index 0000000..13fb92e --- /dev/null +++ b/packages/sync/src/providers/github/normalize.ts @@ -0,0 +1,124 @@ +import type { PrNode } from "./queries.js"; + +export type CiStatus = "success" | "failure" | "pending" | "unknown"; + +export function mapCiStatus(state: string | null | undefined): CiStatus { + switch (state) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + return "failure"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "unknown"; + } +} + +export function mapMergeable(v: PrNode["mergeable"]): boolean | null { + if (v === "MERGEABLE") return true; + if (v === "CONFLICTING") return false; + return null; +} + +export interface ReviewSummary { + approved: string[]; + changesRequested: string[]; +} + +export function summarizeReviews( + reviews: PrNode["reviews"]["nodes"], +): ReviewSummary { + const latest = new Map(); + for (const r of reviews) { + if (!r.author?.login) continue; + if (r.state === "COMMENTED") continue; + latest.set(r.author.login, r.state); + } + const approved: string[] = []; + const changesRequested: string[] = []; + for (const [user, state] of latest) { + if (state === "APPROVED") approved.push(user); + if (state === "CHANGES_REQUESTED") changesRequested.push(user); + } + return { approved, changesRequested }; +} + +export interface NormalizedPr { + id: number | string; + number: number; + title: string; + body: string; + url: string; + repo: string; + createdAt: string; + updatedAt: string; + author: string; + authorAvatar: string; + draft: boolean; + ciStatus: CiStatus; + inMergeQueue: boolean; + autoMerge: boolean; + autoMergeAllowed: boolean; + headBranch: string; + baseBranch: string; + reviews: ReviewSummary; + reviewDecision: PrNode["reviewDecision"]; + mergeStateStatus: PrNode["mergeStateStatus"]; + unresolvedThreadCount: number; + additions: number; + deletions: number; + commits: number; + commentCount: number; + labels: string[]; + mergeable: boolean | null; + autoAssigned?: boolean; +} + +export function normalizePr(node: PrNode): NormalizedPr { + const ci = mapCiStatus( + node.commits.nodes[0]?.commit.statusCheckRollup?.state, + ); + const unresolved = node.reviewThreads.nodes.filter( + (t) => !t.isResolved, + ).length; + // Match the server payload's commentCount = conversation comments + review + // comments. Review comments are summed across review threads. Capped at the + // first 100 threads (the GraphQL page size) — an undercount only on PRs with + // an unusually large number of distinct threads. + const reviewCommentCount = node.reviewThreads.nodes.reduce( + (sum, t) => sum + t.comments.totalCount, + 0, + ); + return { + id: node.databaseId ?? node.id, + number: node.number, + title: node.title, + body: node.body ?? "", + url: node.url, + repo: node.repository.nameWithOwner, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + author: node.author?.login ?? "unknown", + authorAvatar: node.author?.avatarUrl ?? "", + draft: node.isDraft, + ciStatus: ci, + inMergeQueue: node.mergeQueueEntry != null, + autoMerge: node.autoMergeRequest != null, + autoMergeAllowed: node.repository.autoMergeAllowed, + headBranch: node.headRefName, + baseBranch: node.baseRefName, + reviews: summarizeReviews(node.reviews.nodes), + reviewDecision: node.reviewDecision, + mergeStateStatus: node.mergeStateStatus, + unresolvedThreadCount: unresolved, + additions: node.additions, + deletions: node.deletions, + commits: node.commitsTotal.totalCount, + commentCount: node.comments.totalCount + reviewCommentCount, + labels: node.labels.nodes.map((l) => l.name), + mergeable: mapMergeable(node.mergeable), + }; +} diff --git a/packages/sync/src/providers/github/notificationUrl.ts b/packages/sync/src/providers/github/notificationUrl.ts new file mode 100644 index 0000000..7bf363c --- /dev/null +++ b/packages/sync/src/providers/github/notificationUrl.ts @@ -0,0 +1,68 @@ +// Convert a GitHub *API* URL into its corresponding *HTML* URL. +// Ported from packages/server/src/fetchers.ts — the notifications API only +// returns the subject's API URL; we have to derive the html link ourselves. + +export function notificationHtmlUrl( + apiUrl: string | null | undefined, + type: string | null | undefined, + repoFullName: string, + apiBaseUrl: string, + latestCommentUrl?: string | null, +): string { + const htmlBase = htmlBaseFromApiBase(apiBaseUrl); + const repoUrl = `${htmlBase}/${repoFullName}`; + + if (!apiUrl) { + if (type === "Discussion") return `${repoUrl}/discussions`; + if (type === "Release") return `${repoUrl}/releases`; + return repoUrl; + } + + let path: string; + try { + path = new URL(apiUrl).pathname; + } catch { + return repoUrl; + } + + path = path.replace(/^\/api\/v3\/repos\//, "/").replace(/^\/repos\//, "/"); + path = path + .replace(/^\/([^/]+\/[^/]+)\/pulls\//, "/$1/pull/") + .replace(/^\/([^/]+\/[^/]+)\/commits\//, "/$1/commit/"); + const releaseMatch = path.match(/^\/([^/]+\/[^/]+)\/releases\/\d+$/); + if (releaseMatch) return `${htmlBase}/${releaseMatch[1]}/releases`; + + const fragment = commentFragment(latestCommentUrl); + return `${htmlBase}${path}${fragment ?? ""}`; +} + +function commentFragment( + commentApiUrl: string | null | undefined, +): string | null { + if (!commentApiUrl) return null; + let path: string; + try { + path = new URL(commentApiUrl).pathname; + } catch { + return null; + } + path = path.replace(/^\/api\/v3/, ""); + let m: RegExpMatchArray | null; + if ((m = path.match(/\/issues\/comments\/(\d+)$/))) + return `#issuecomment-${m[1]}`; + if ((m = path.match(/\/pulls\/comments\/(\d+)$/))) + return `#discussion_r${m[1]}`; + if ((m = path.match(/\/repos\/[^/]+\/[^/]+\/comments\/(\d+)$/))) + return `#commitcomment-${m[1]}`; + return null; +} + +function htmlBaseFromApiBase(apiBaseUrl: string): string { + try { + const u = new URL(apiBaseUrl); + if (u.hostname === "api.github.com") return "https://github.com"; + return `${u.protocol}//${u.host}`; + } catch { + return "https://github.com"; + } +} diff --git a/packages/sync/src/providers/github/queries.ts b/packages/sync/src/providers/github/queries.ts new file mode 100644 index 0000000..d1bb76d --- /dev/null +++ b/packages/sync/src/providers/github/queries.ts @@ -0,0 +1,229 @@ +export const PR_FIELDS = /* GraphQL */ ` + fragment PrFields on PullRequest { + id + databaseId + number + title + body + url + createdAt + updatedAt + isDraft + mergeable + mergeStateStatus + reviewDecision + additions + deletions + headRefName + baseRefName + author { + login + avatarUrl + } + repository { + nameWithOwner + autoMergeAllowed + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + reviews(last: 50) { + nodes { + state + author { + login + } + } + } + reviewThreads(first: 100) { + totalCount + nodes { + isResolved + comments { + totalCount + } + } + } + autoMergeRequest { + enabledAt + } + mergeQueueEntry { + id + } + labels(first: 20) { + nodes { + name + } + } + comments { + totalCount + } + commitsTotal: commits { + totalCount + } + } +`; + +export const SEARCH_PRS = /* GraphQL */ ` + ${PR_FIELDS} + query ($q: String!, $first: Int!) { + search(query: $q, type: ISSUE, first: $first) { + nodes { + ... on PullRequest { + ...PrFields + } + } + } + rateLimit { + cost + remaining + resetAt + } + } +`; + +export interface RateLimitInfo { + cost: number; + remaining: number; + resetAt: string; +} + +export interface PrNode { + id: string; + databaseId: number | null; + number: number; + title: string; + body: string | null; + url: string; + createdAt: string; + updatedAt: string; + isDraft: boolean; + mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN" | null; + mergeStateStatus: + | "BEHIND" + | "BLOCKED" + | "CLEAN" + | "DIRTY" + | "DRAFT" + | "HAS_HOOKS" + | "UNKNOWN" + | "UNSTABLE" + | null; + reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; + additions: number; + deletions: number; + headRefName: string; + baseRefName: string; + author: { login: string; avatarUrl: string } | null; + repository: { nameWithOwner: string; autoMergeAllowed: boolean }; + commits: { + nodes: Array<{ + commit: { + statusCheckRollup: { state: string } | null; + }; + }>; + }; + reviews: { + nodes: Array<{ state: string; author: { login: string } | null }>; + }; + reviewThreads: { + totalCount: number; + nodes: Array<{ isResolved: boolean; comments: { totalCount: number } }>; + }; + autoMergeRequest: { enabledAt: string } | null; + mergeQueueEntry: { id: string } | null; + labels: { nodes: Array<{ name: string }> }; + comments: { totalCount: number }; + commitsTotal: { totalCount: number }; +} + +export interface SearchPrsResponse { + search: { nodes: Array }; + rateLimit: RateLimitInfo; +} + +export const SEARCH_REVIEWS = /* GraphQL */ ` + ${PR_FIELDS} + query ($q: String!, $first: Int!) { + search(query: $q, type: ISSUE, first: $first) { + nodes { + ... on PullRequest { + ...PrFields + reviewRequests(first: 50) { + nodes { + requestedReviewer { + __typename + ... on User { login } + } + } + } + timelineItems( + itemTypes: [REVIEW_REQUESTED_EVENT, REVIEW_REQUEST_REMOVED_EVENT] + first: 100 + ) { + nodes { + __typename + ... on ReviewRequestedEvent { + createdAt + actor { + __typename + login + } + requestedReviewer { + __typename + ... on User { login } + } + } + ... on ReviewRequestRemovedEvent { + createdAt + actor { + __typename + login + } + requestedReviewer { + __typename + ... on User { login } + } + } + } + } + } + } + } + rateLimit { + cost + remaining + resetAt + } + } +`; + +export interface ReviewRequestedReviewer { + __typename: "User" | "Team" | string; + login?: string; +} + +export interface TimelineEventNode { + __typename: "ReviewRequestedEvent" | "ReviewRequestRemovedEvent"; + createdAt: string; + actor: { __typename: string; login: string } | null; + requestedReviewer: ReviewRequestedReviewer | null; +} + +export interface ReviewPrNode extends PrNode { + reviewRequests: { + nodes: Array<{ requestedReviewer: ReviewRequestedReviewer | null }>; + }; + timelineItems: { nodes: TimelineEventNode[] }; +} + +export interface SearchReviewsResponse { + search: { nodes: Array }; + rateLimit: RateLimitInfo; +} From 45fb1f78081df1c339987240eb0e94df40a9ff41 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:13:01 +0200 Subject: [PATCH 07/10] sync(engine): once/loop orchestration with rate-limit guard createSyncEngine drives a sync cycle across instances and kinds: reconcile configured instances, fetch PRs/reviews/notifications, persist via the repository. runOnce does a single pass; start runs it on an interval. A rate-limit floor skips fetches when the remaining budget is low so a cycle can't exhaust the GitHub quota. printSummary renders a cycle summary. --- packages/sync/src/engine.test.ts | 148 ++++++++++++++++ packages/sync/src/engine.ts | 294 +++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 packages/sync/src/engine.test.ts create mode 100644 packages/sync/src/engine.ts diff --git a/packages/sync/src/engine.test.ts b/packages/sync/src/engine.test.ts new file mode 100644 index 0000000..1f778e5 --- /dev/null +++ b/packages/sync/src/engine.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { openCache } from "./cache/open.js"; +import { createSqliteRepository } from "./cache/store.js"; +import type { GitHubInstance } from "./config.js"; +import { createSyncEngine, reconcileInstances } from "./engine.js"; + +// These tests exercise the engine's lifecycle and DI surface without hitting +// the network. We inject loadInstances to return a fixed list, and avoid any +// real provider fetches by keeping the instance list empty (no targets to +// fetch) or by targeting an unknown instance, which throws before any fetch. + +describe("createSyncEngine", () => { + let cacheRoot: string; + let prevXdg: string | undefined; + let close: () => void; + + beforeEach(() => { + cacheRoot = mkdtempSync(join(tmpdir(), "ghd-engine-")); + prevXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = cacheRoot; + }); + + afterEach(() => { + close?.(); + if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevXdg; + rmSync(cacheRoot, { recursive: true, force: true }); + }); + + function setupEngine(instances: GitHubInstance[] = []) { + const { db } = openCache(); + close = () => db.close(); + const repo = createSqliteRepository(db); + const engine = createSyncEngine({ + repo, + loadInstances: async () => instances, + }); + return { repo, engine }; + } + + test("runOnce with no instances returns an empty cycle", async () => { + const { engine } = setupEngine([]); + const summary = await engine.runOnce(); + expect(summary.results).toEqual([]); + expect(summary.durationMs).toBeGreaterThanOrEqual(0); + }); + + test("runOnce reconciles instances before running providers", async () => { + const { repo, engine } = setupEngine([ + { + id: "a", + label: "A", + baseUrl: "https://api.github.com", + token: "x", + username: "u", + }, + ]); + // Targeting an unknown instance is rejected. + await expect(engine.runOnce({ instance: "missing" })).rejects.toThrow( + "unknown instance: missing", + ); + // But the reconcile step still upserted the configured instance into the DB. + expect(repo.listInstanceIds()).toEqual(["a"]); + }); + + test("start + stop lifecycle", async () => { + const { engine } = setupEngine([]); + expect(engine.isRunning()).toBe(false); + + let cycles = 0; + engine.start({ + intervalMs: 10, + onCycle: () => { + cycles += 1; + }, + }); + expect(engine.isRunning()).toBe(true); + + // Calling start while already running is a programmer error. + expect(() => engine.start()).toThrow("already running"); + + // Give the loop time to run at least one cycle. + await new Promise((r) => setTimeout(r, 30)); + await engine.stop(); + expect(engine.isRunning()).toBe(false); + expect(cycles).toBeGreaterThan(0); + + // stop() is idempotent. + await engine.stop(); + }); + + test("stop wakes the loop immediately from its sleep interval", async () => { + const { engine } = setupEngine([]); + engine.start({ intervalMs: 60_000 }); + // Let the first cycle finish, then trigger stop. If sleep weren't + // interruptible, this test would time out waiting 60s. + await new Promise((r) => setTimeout(r, 20)); + const stopStart = Date.now(); + await engine.stop(); + expect(Date.now() - stopStart).toBeLessThan(500); + }); + + test("reconcileInstances handles add + remove + label-edit", () => { + const { repo } = setupEngine([]); + reconcileInstances(repo, [ + { + id: "a", + label: "A", + baseUrl: "https://api.github.com", + token: "x", + username: "u", + }, + { + id: "b", + label: "B", + baseUrl: "https://api.github.com", + token: "x", + username: "u", + }, + ]); + expect(repo.listInstanceIds()).toEqual(["a", "b"]); + + const { added, removed } = reconcileInstances(repo, [ + { + id: "a", + label: "A-renamed", + baseUrl: "https://api.github.com", + token: "x", + username: "u", + }, + { + id: "c", + label: "C", + baseUrl: "https://api.github.com", + token: "x", + username: "u", + }, + ]); + expect(added).toEqual(["c"]); + expect(removed).toEqual(["b"]); + expect(repo.listInstances().find((i) => i.id === "a")?.label).toBe( + "A-renamed", + ); + }); +}); diff --git a/packages/sync/src/engine.ts b/packages/sync/src/engine.ts new file mode 100644 index 0000000..48d9133 --- /dev/null +++ b/packages/sync/src/engine.ts @@ -0,0 +1,294 @@ +import type { Repository } from "./cache/store.js"; +import { type GitHubInstance, loadInstances } from "./config.js"; +import { fetchNotifications } from "./providers/github/fetchNotifications.js"; +import { fetchAuthoredPrs } from "./providers/github/fetchPrs.js"; +import { fetchReviews } from "./providers/github/fetchReviews.js"; + +const RATE_LIMIT_FLOOR = 200; +const DEFAULT_INTERVAL_MS = 25_000; + +export type SyncKind = "prs" | "reviews" | "notifications"; + +export interface SyncCycleOptions { + instance?: string; + kind?: SyncKind; +} + +export interface SyncCycleSummary { + startedAt: string; + finishedAt: string; + durationMs: number; + results: InstanceResult[]; +} + +export interface InstanceResult { + instanceId: string; + fetches: FetchSummary[]; +} + +export interface FetchSummary { + kind: SyncKind | "authored" | "review_requested"; + count: number; + notModified?: boolean; + rateRemaining: number | null; + rateResetAt?: string | null; + error?: string; +} + +export interface SyncEngineDeps { + repo: Repository; + // Default reads ~/.config/github-dashboard/config.yml and probes each + // instance via Octokit. Override in tests to inject fake instances without + // touching the filesystem or network. + loadInstances?: () => Promise; +} + +export interface SyncLoopOptions { + // Milliseconds to wait AFTER a cycle finishes before starting the next. + // Not a fixed wall-clock interval — prevents pile-up if a cycle runs long. + // Default: 25_000. + intervalMs?: number; + // Called after each completed cycle with the summary. Useful for logging. + onCycle?: (summary: SyncCycleSummary) => void; + // Called when a cycle's loadInstances() or any internal step throws. + // Individual provider fetches are already caught internally and surfaced + // as FetchSummary.error, so this only fires on outer/structural failures. + onError?: (err: unknown) => void; +} + +export interface SyncEngine { + runOnce(opts?: SyncCycleOptions): Promise; + start(opts?: SyncLoopOptions): void; + stop(): Promise; + isRunning(): boolean; +} + +export function createSyncEngine(deps: SyncEngineDeps): SyncEngine { + const { repo, loadInstances: loadInstancesImpl = loadInstances } = deps; + + // Loop state. `running` is the canonical "are we looping" flag. `loopDone` + // resolves when the loop's async function actually exits — stop() awaits it + // so callers can be sure no in-flight cycle is still mutating the repo. + let running = false; + let stopRequested = false; + let loopDone: Promise | null = null; + let sleepCanceller: (() => void) | null = null; + + async function runOnce( + opts: SyncCycleOptions = {}, + ): Promise { + const startedAt = new Date(); + const configured = await loadInstancesImpl(); + reconcileInstances(repo, configured); + + const targets = opts.instance + ? configured.filter((i) => i.id === opts.instance) + : configured; + if (opts.instance && targets.length === 0) { + throw new Error(`unknown instance: ${opts.instance}`); + } + + const results: InstanceResult[] = []; + for (const instance of targets) { + const fetches: FetchSummary[] = []; + const wantPrs = !opts.kind || opts.kind === "prs"; + const wantReviews = !opts.kind || opts.kind === "reviews"; + const wantNotifications = !opts.kind || opts.kind === "notifications"; + + if (wantPrs) { + fetches.push( + await guardedRun(repo, instance.id, "authored", () => + fetchAuthoredPrs(repo, instance), + ), + ); + } + if (wantReviews) { + fetches.push( + await guardedRun(repo, instance.id, "review_requested", () => + fetchReviews(repo, instance), + ), + ); + } + if (wantNotifications) { + fetches.push( + await guardedRun(repo, instance.id, "notifications", () => + fetchNotifications(repo, instance), + ), + ); + } + results.push({ instanceId: instance.id, fetches }); + } + + const finishedAt = new Date(); + return { + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + results, + }; + } + + function start(opts: SyncLoopOptions = {}): void { + if (running) { + throw new Error("SyncEngine is already running"); + } + const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS; + running = true; + stopRequested = false; + + loopDone = (async () => { + try { + while (!stopRequested) { + try { + const summary = await runOnce(); + opts.onCycle?.(summary); + } catch (err) { + opts.onError?.(err); + } + if (stopRequested) break; + await interruptibleSleep(intervalMs, (cancel) => { + sleepCanceller = cancel; + }); + sleepCanceller = null; + } + } finally { + running = false; + sleepCanceller = null; + } + })(); + } + + async function stop(): Promise { + if (!running) return; + stopRequested = true; + sleepCanceller?.(); + await loopDone; + loopDone = null; + } + + return { + runOnce, + start, + stop, + isRunning: () => running, + }; +} + +// Diff configured instances against what's in the DB. Inserts new ones, +// cascade-deletes removed ones (FK ON DELETE CASCADE wipes their prs / +// notifications / sync_state rows). Exported because it's a useful primitive +// on its own — exercised in reconciliation tests without spinning up an +// engine. +export function reconcileInstances( + repo: Repository, + configured: GitHubInstance[], +): { added: string[]; removed: string[] } { + const inDb = new Set(repo.listInstanceIds()); + const inConfig = new Set(configured.map((i) => i.id)); + + const added: string[] = []; + const removed: string[] = []; + + for (const instance of configured) { + repo.upsertInstance({ + id: instance.id, + label: instance.label, + baseUrl: instance.baseUrl, + username: instance.username, + }); + if (!inDb.has(instance.id)) added.push(instance.id); + } + + for (const id of inDb) { + if (!inConfig.has(id)) { + repo.deleteInstance(id); + removed.push(id); + } + } + + return { added, removed }; +} + +// Skip the fetch when stored headroom for this (instance, kind) is below the +// floor and reset is still in the future. Prevents the engine from burning +// the last 200 requests on a polling cycle. +async function guardedRun( + repo: Repository, + instanceId: string, + kind: FetchSummary["kind"], + fn: () => Promise<{ + count: number; + rateRemaining: number | null; + rateResetAt?: string; + notModified?: boolean; + }>, +): Promise { + const state = repo.getSyncState(instanceId, kind); + if ( + state?.rate_remaining != null && + state.rate_remaining < RATE_LIMIT_FLOOR && + state.rate_reset_at && + Date.parse(state.rate_reset_at) > Date.now() + ) { + return { + kind, + count: 0, + rateRemaining: state.rate_remaining, + error: `rate-limit floor (${RATE_LIMIT_FLOOR}) — waiting for reset at ${state.rate_reset_at}`, + }; + } + + try { + const r = await fn(); + return { + kind, + count: r.count, + notModified: r.notModified, + rateRemaining: r.rateRemaining, + rateResetAt: r.rateResetAt ?? null, + }; + } catch (err) { + return { + kind, + count: 0, + rateRemaining: null, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// Sleep that resolves on either timer or the canceller being called. The +// canceller is captured so stop() can wake the loop immediately instead of +// waiting up to `intervalMs` for the next iteration. +function interruptibleSleep( + ms: number, + capture: (cancel: () => void) => void, +): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + capture(() => { + clearTimeout(timer); + resolve(); + }); + }); +} + +// Pretty-print a cycle summary. The CLI uses this; the server probably won't. +export function printSummary(summary: SyncCycleSummary): void { + const lines: string[] = []; + lines.push(`sync cycle ${summary.startedAt} (${summary.durationMs}ms)`); + for (const result of summary.results) { + lines.push(` instance: ${result.instanceId}`); + for (const fetch of result.fetches) { + const status = fetch.error + ? `ERROR ${fetch.error}` + : fetch.notModified + ? "304 not modified" + : `${fetch.count} rows`; + const rate = + fetch.rateRemaining != null ? ` (rate ${fetch.rateRemaining})` : ""; + lines.push(` ${fetch.kind.padEnd(18)} ${status}${rate}`); + } + } + process.stdout.write(`${lines.join("\n")}\n`); +} From aaf6cd8cdfc25d8b743cf12cac5a10a577be620e Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:13:16 +0200 Subject: [PATCH 08/10] sync(cli): ghd-sync CLI (once/loop/status/wipe) The ghd-sync entrypoint: once runs a single sync, loop runs continuously, status prints cached counts and per-resource sync state, wipe removes the cache file. Wires openCache + repository + engine together and adds a root-level `sync` script for convenience. --- package.json | 1 + packages/sync/src/cli.ts | 270 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 packages/sync/src/cli.ts diff --git a/package.json b/package.json index a49f97d..b6e5e79 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "fmt:check": "oxfmt --check .", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", + "sync": "pnpm --filter sync cli", "prepare": "husky" }, "devDependencies": { diff --git a/packages/sync/src/cli.ts b/packages/sync/src/cli.ts new file mode 100644 index 0000000..a95593d --- /dev/null +++ b/packages/sync/src/cli.ts @@ -0,0 +1,270 @@ +#!/usr/bin/env node +import { parseArgs } from "node:util"; +import { openCache, wipeCacheFile } from "./cache/open.js"; +import { CACHE_SCHEMA_VERSION } from "./cache/schema.js"; +import { type Repository, createSqliteRepository } from "./cache/store.js"; +import { type SyncKind, createSyncEngine, printSummary } from "./engine.js"; + +const USAGE = `ghd-sync — github-dashboard sync engine + +Usage: + ghd-sync [options] + +Commands: + once [--instance ] [--kind ] + Run a single sync cycle + loop [--interval ] + Run cycles in a loop, sleeping seconds + after each one finishes (default: 25). Ctrl-C to stop. + status Print cache path, schema version, row counts, last sync per instance + wipe Delete the cache file and its sidecars + +Options: + -h, --help Show this help +`; + +async function main(argv: string[]): Promise { + const [command, ...rest] = argv; + + if ( + !command || + command === "-h" || + command === "--help" || + command === "help" + ) { + process.stdout.write(USAGE); + return 0; + } + + switch (command) { + case "once": + return onceCommand(rest); + case "loop": + return loopCommand(rest); + case "status": + return statusCommand(); + case "wipe": + return wipeCommand(); + default: + process.stderr.write(`unknown command: ${command}\n\n${USAGE}`); + return 1; + } +} + +async function onceCommand(args: string[]): Promise { + const { values } = parseArgs({ + args, + options: { + instance: { type: "string" }, + kind: { type: "string" }, + }, + allowPositionals: false, + }); + + const kind = values.kind as SyncKind | undefined; + if (kind && !["prs", "reviews", "notifications"].includes(kind)) { + process.stderr.write( + `invalid --kind: ${kind} (expected prs|reviews|notifications)\n`, + ); + return 1; + } + + const { engine, close } = openEngine(); + try { + const summary = await engine.runOnce({ + instance: values.instance, + kind, + }); + printSummary(summary); + return 0; + } finally { + close(); + } +} + +async function loopCommand(args: string[]): Promise { + const { values } = parseArgs({ + args, + options: { + interval: { type: "string" }, + }, + allowPositionals: false, + }); + + let intervalMs = 25_000; + if (values.interval) { + const seconds = Number(values.interval); + if (!Number.isFinite(seconds) || seconds <= 0) { + process.stderr.write( + `invalid --interval: ${values.interval} (expected positive number of seconds)\n`, + ); + return 1; + } + intervalMs = Math.round(seconds * 1000); + } + + const countdown = createCountdown(intervalMs); + + const { engine, close } = openEngine(); + engine.start({ + intervalMs, + onCycle: (summary) => { + countdown.stop(); + printSummary(summary); + countdown.start(); + }, + onError: (err) => { + countdown.stop(); + process.stderr.write( + `cycle error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + countdown.start(); + }, + }); + try { + await waitForSigint(); + countdown.stop(); + await engine.stop(); + return 0; + } finally { + close(); + } +} + +// Live countdown rendered on a single line using \r. Skipped when stdout +// isn't a TTY (logs, pipes) so we don't litter files with CR-escape spam. +// After the countdown elapses, switches to "syncing..." until the next +// onCycle clears and restarts it. +function createCountdown(intervalMs: number): { + start: () => void; + stop: () => void; +} { + if (!process.stdout.isTTY) { + return { start: () => {}, stop: () => {} }; + } + let timer: NodeJS.Timeout | null = null; + let remainingSec = 0; + const render = (text: string) => { + process.stdout.write(`\r\x1b[K${text}`); + }; + const clearLine = () => { + process.stdout.write("\r\x1b[K"); + }; + return { + start: () => { + remainingSec = Math.ceil(intervalMs / 1000); + render(`next sync in ${remainingSec}s`); + timer = setInterval(() => { + remainingSec -= 1; + if (remainingSec <= 0) { + if (timer) clearInterval(timer); + timer = null; + render("syncing..."); + return; + } + render(`next sync in ${remainingSec}s`); + }, 1000); + }, + stop: () => { + if (timer) { + clearInterval(timer); + timer = null; + } + clearLine(); + }, + }; +} + +function openEngine() { + const { db, path, wiped } = openCache(); + if (wiped) { + process.stderr.write( + `cache wiped (schema version mismatch) — recreated at ${path}\n`, + ); + } + const repo = createSqliteRepository(db); + const engine = createSyncEngine({ repo }); + return { db, engine, close: () => db.close() }; +} + +function statusCommand(): number { + const { db, path } = openCache(); + try { + printStatus(createSqliteRepository(db), path); + return 0; + } finally { + db.close(); + } +} + +function printStatus(repo: Repository, path: string): void { + const version = repo.getSchemaVersion(); + process.stdout.write(`cache: ${path}\n`); + process.stdout.write( + `schema version: ${version} (code: ${CACHE_SCHEMA_VERSION})\n`, + ); + + const instances = repo.listInstances(); + if (instances.length === 0) { + process.stdout.write( + "\nno instances in cache yet — run 'ghd-sync once' to populate\n", + ); + return; + } + + for (const inst of instances) { + process.stdout.write( + `\ninstance: ${inst.id} (${inst.label}, ${inst.username})\n`, + ); + + const prCounts = repo.countPrsByKind(inst.id); + for (const c of prCounts) { + process.stdout.write(` prs (${c.kind}): ${c.count}\n`); + } + if (prCounts.length === 0) process.stdout.write(" prs: 0\n"); + + process.stdout.write( + ` notifications: ${repo.countNotifications(inst.id)}\n`, + ); + + for (const s of repo.listSyncStates(inst.id)) { + const rate = s.rate_remaining != null ? `, rate ${s.rate_remaining}` : ""; + const reset = s.rate_reset_at ? ` (resets ${s.rate_reset_at})` : ""; + process.stdout.write( + ` last ${s.kind.padEnd(18)} ${s.last_run_at ?? "never"}${rate}${reset}\n`, + ); + } + } +} + +function wipeCommand(): number { + const { existed, path } = wipeCacheFile(); + if (existed) { + process.stdout.write(`wiped ${path}\n`); + } else { + process.stdout.write(`nothing to wipe (no cache at ${path})\n`); + } + return 0; +} + +function waitForSigint(): Promise { + return new Promise((resolve) => { + process.once("SIGINT", () => { + process.stderr.write("\nstopping (SIGINT)...\n"); + resolve(); + }); + }); +} + +main(process.argv.slice(2)).then( + (code) => process.exit(code), + (err) => { + process.stderr.write( + `fatal: ${err instanceof Error ? err.message : String(err)}\n`, + ); + if (err instanceof Error && err.stack) { + process.stderr.write(`${err.stack}\n`); + } + process.exit(1); + }, +); From 18eb08785d32e94df884260e732c37b28023bcd7 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Mon, 15 Jun 2026 10:13:30 +0200 Subject: [PATCH 09/10] sync: public package surface index.ts re-exports the cache, config, and engine APIs so consumers (the server, the CLI, future adopters) import only from the package root and the internal layout stays free to change. index.test.ts pins the exported names. --- packages/sync/src/index.test.ts | 69 +++++++++++++++++++++++++++++++++ packages/sync/src/index.ts | 56 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 packages/sync/src/index.test.ts create mode 100644 packages/sync/src/index.ts diff --git a/packages/sync/src/index.test.ts b/packages/sync/src/index.test.ts new file mode 100644 index 0000000..4e92091 --- /dev/null +++ b/packages/sync/src/index.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +// Import only from the public surface — anything missing here means callers +// (notably the server in Phase B of #72) would have to reach into internals. +import { + CACHE_SCHEMA_VERSION, + type GitHubInstance, + type Repository, + type SyncEngine, + createSqliteRepository, + createSyncEngine, + openCache, + reconcileInstances, +} from "./index.js"; + +describe("public surface", () => { + let cacheRoot: string; + let prevXdg: string | undefined; + + beforeEach(() => { + cacheRoot = mkdtempSync(join(tmpdir(), "ghd-public-")); + prevXdg = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = cacheRoot; + }); + + afterEach(() => { + if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevXdg; + rmSync(cacheRoot, { recursive: true, force: true }); + }); + + test("composes from public exports the way the server would", () => { + const { db, path } = openCache(); + try { + expect(path).toContain("github-dashboard/cache.sqlite"); + + const repo: Repository = createSqliteRepository(db); + expect(repo.getSchemaVersion()).toBe(CACHE_SCHEMA_VERSION); + + const fakeInstances: GitHubInstance[] = [ + { + id: "github-com", + label: "github.com", + baseUrl: "https://api.github.com", + token: "redacted", + username: "u", + }, + ]; + const { added, removed } = reconcileInstances(repo, fakeInstances); + expect(added).toEqual(["github-com"]); + expect(removed).toEqual([]); + + expect(repo.listInstanceIds()).toEqual(["github-com"]); + expect(repo.getPrPayloads("github-com", "authored")).toEqual([]); + expect(repo.listNotifications("github-com")).toEqual([]); + + const engine: SyncEngine = createSyncEngine({ + repo, + // Inject fake config so we don't hit the real ~/.config or network. + loadInstances: async () => fakeInstances, + }); + expect(engine.isRunning()).toBe(false); + } finally { + db.close(); + } + }); +}); diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts new file mode 100644 index 0000000..468712d --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,56 @@ +// Public surface of the sync package. Consumers (the server, the CLI, future +// adopters) should import only from here so the internal layout can change +// without breaking callers. +// +// Typical composition: +// const { db } = openCache(); +// const repo = createSqliteRepository(db); +// const engine = createSyncEngine({ repo }); +// await engine.runOnce(); +// engine.start({ intervalMs: 25_000, onCycle: console.log }); + +// Cache file (raw SQLite open with version check) + path utilities +export { + type Cache, + type OpenCacheResult, + openCache, + wipeCacheFile, +} from "./cache/open.js"; +export { CACHE_SCHEMA_VERSION } from "./cache/schema.js"; +export { resolveCachePath } from "./cache/path.js"; + +// Repository (storage contract + sqlite implementation) +export { + type InstanceRow, + type InstanceSummary, + type NotificationRow, + type PrKind, + type PrKindCount, + type PrRow, + type Repository, + type SyncStateRow, + createSqliteRepository, +} from "./cache/store.js"; + +// Config (reading ~/.config/github-dashboard/config.yml) +export { + type GitHubInstance, + instanceIdFromDomain, + loadInstances, + resolveConfigPath, +} from "./config.js"; + +// Sync engine +export { + type FetchSummary, + type InstanceResult, + type SyncCycleOptions, + type SyncCycleSummary, + type SyncEngine, + type SyncEngineDeps, + type SyncKind, + type SyncLoopOptions, + createSyncEngine, + printSummary, + reconcileInstances, +} from "./engine.js"; From 37379b9037d6518ab1b1e33354b808bf1c626215 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Tue, 16 Jun 2026 09:47:13 +0200 Subject: [PATCH 10/10] sync(config): cache resolved username across cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadInstances() probed users.getAuthenticated() per instance on every call. The engine re-reads config each cycle (so reconcile sees added/removed instances), so in loop mode this spent one REST request per instance per cycle — unaccounted by the rate-limit floor — to re-resolve a username that effectively never changes. Memoize the probe by (baseUrl, token) for the process lifetime; a restart re-probes. --- packages/sync/src/config.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index a945ba5..fa23fd8 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -57,6 +57,31 @@ function domainToApiBase(domain: string): string { return `${url.origin}/api/v3`; } +// Resolving a token's username costs one REST call (users.getAuthenticated). +// The engine re-reads config every cycle so reconcile sees added/removed +// instances, but a token's username effectively never changes — caching it by +// (baseUrl, token) keeps loop mode from spending a request per instance per +// cycle, outside the rate-limit floor accounting. Process-lifetime cache; a +// restart re-probes. +const usernameCache = new Map(); + +async function resolveUsername( + baseUrl: string, + token: string, +): Promise { + const key = `${baseUrl} ${token}`; + const cached = usernameCache.get(key); + if (cached !== undefined) { + return cached; + } + + const client = new Octokit({ auth: token, baseUrl }); + const { data } = await client.users.getAuthenticated(); + usernameCache.set(key, data.login); + + return data.login; +} + // The yaml parser echoes the offending source line in `err.message`, which // can include a token if the malformed line is `token: ghp_...`. Strip it // down to position info only. @@ -92,14 +117,13 @@ export async function loadInstances(): Promise { for (const entry of entries) { const id = instanceIdFromDomain(entry.domain); const baseUrl = domainToApiBase(entry.domain); - const client = new Octokit({ auth: entry.token, baseUrl }); - const { data } = await client.users.getAuthenticated(); + const username = await resolveUsername(baseUrl, entry.token); instances.push({ id, label: entry.label || entry.domain, baseUrl, token: entry.token, - username: data.login, + username, }); } return instances;