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..b6e5e79 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,18 @@ "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 .", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", + "sync": "pnpm --filter sync cli", "prepare": "husky" }, "devDependencies": { @@ -27,6 +28,7 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "better-sqlite3", "esbuild", "electron" ] 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/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/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 +); +`; 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; + }, + }; +} 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); + }, +); diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts new file mode 100644 index 0000000..fa23fd8 --- /dev/null +++ b/packages/sync/src/config.ts @@ -0,0 +1,130 @@ +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`; +} + +// 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. +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 username = await resolveUsername(baseUrl, entry.token); + instances.push({ + id, + label: entry.label || entry.domain, + baseUrl, + token: entry.token, + username, + }); + } + return instances; +} 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`); +} 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"; 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; +} 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/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..c08222a 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: @@ -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: