Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A keyboard-driven dashboard for your GitHub pull requests, review requests and n
There are currently two clients:

- **TUI** (`packages/cli`) — terminal UI built on the new sync engine (`packages/sync`, SQLite cache + bulk GraphQL). Browse PRs/reviews/notifications, read descriptions and comments, review diffs and post line comments, toggle draft/auto-merge, approve. Press `?` for keyboard shortcuts.
- **Web app** (`packages/web` + `packages/server`, optionally wrapped in Electron via `packages/desktop`) — browser dashboard backed by its own Hono API server and cache. **Not yet migrated to the sync engine.** A pre-built macOS app is on the [Releases page](https://github.com/AntonNiklasson/github-dashboard/releases).
- **Web app** (`packages/web` + `packages/server`, optionally wrapped in Electron via `packages/desktop`) — browser dashboard backed by a Hono API adapter to the shared sync engine and SQLite cache. A pre-built macOS app is on the [Releases page](https://github.com/AntonNiklasson/github-dashboard/releases).

## Development

Expand Down Expand Up @@ -57,3 +57,7 @@ TUI state lives in XDG dirs and is safe to delete:

- `~/.cache/github-dashboard/cache.sqlite` — sync cache
- `~/.local/state/github-dashboard/tui.json` — last view, instance and sorting

### Desktop/web sync engine scope

The desktop and web clients use `packages/sync` for all GitHub access, including authentication, list synchronization, comments and file diffs. Supported writes are approval, draft/ready toggling, auto-merge toggling and notification dismissal. Direct merge/close, title editing, CI reruns, posting conversation comments, closed-PR search, colleague browsing, and detailed commit/check lists are currently unavailable; use “Open in browser” for those operations. The local Hono HTTP API remains the renderer transport, but owns no GitHub client or live cache.
1 change: 0 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
},
"dependencies": {
"@hono/node-server": "^1.14.1",
"@octokit/rest": "^22.0.1",
"hono": "^4.7.6",
"yaml": "^2.8.3",
"zod": "^4.4.3",
Expand Down
139 changes: 23 additions & 116 deletions packages/server/src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,120 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FsMock } from "./test-utils/fs-mock.js";

vi.mock("node:fs", async () => {
const { createFsMock } = await import("./test-utils/fs-mock.js");
return createFsMock();
});

async function freshCache() {
vi.resetModules();
return import("./cache.js");
}

async function getFsMock(): Promise<FsMock> {
return (await import("node:fs")) as unknown as FsMock;
}

beforeEach(async () => {
(await getFsMock()).__store.clear();
import { afterEach, expect, it, vi } from "vitest";
const fs = vi.hoisted(() => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() =>
JSON.stringify({ "github:prs": { data: [{ number: 1 }] } }),
),
}));
vi.mock("node:fs", () => fs);
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});

describe("cache in-memory", () => {
it("returns null for missing key", async () => {
const { getCached } = await freshCache();
expect(getCached("missing")).toBeNull();
});

it("roundtrips data via set/get", async () => {
const { getCached, setCached } = await freshCache();
setCached("k", { a: 1 });
expect(getCached<{ a: number }>("k")).toEqual({ a: 1 });
});

it("cacheAge is null when key is missing", async () => {
const { cacheAge } = await freshCache();
expect(cacheAge("missing")).toBeNull();
});

it("cacheAge is near-zero immediately after setCached", async () => {
const { setCached, cacheAge } = await freshCache();
setCached("k", 1);
const age = cacheAge("k");
expect(age).not.toBeNull();
expect(age!).toBeGreaterThanOrEqual(0);
expect(age!).toBeLessThan(1000);
});

it("setCached overwrites prior value", async () => {
const { setCached, getCached } = await freshCache();
setCached("k", "first");
setCached("k", "second");
expect(getCached("k")).toBe("second");
});
});

describe("cache persistence", () => {
it("setCached writes the store to disk as JSON", async () => {
const { setCached } = await freshCache();
const fs = await getFsMock();
setCached("k", { hello: "world" });
expect(fs.writeFileSync).toHaveBeenCalled();
const written = fs.writeFileSync.mock.calls.at(-1)![1];
const parsed = JSON.parse(written);
expect(parsed.k.data).toEqual({ hello: "world" });
expect(typeof parsed.k.updatedAt).toBe("string");
});

it("loadCache reads prior entries from disk", async () => {
const fs = await getFsMock();
// Discover the path cache.ts writes to, then seed it with known content.
const probe = await freshCache();
probe.setCached("probe", 0);
const path = fs.writeFileSync.mock.calls.at(-1)![0];

fs.__store.clear();
fs.__store.set(
path,
JSON.stringify({
foo: { data: "bar", updatedAt: new Date().toISOString() },
}),
);

const reloaded = await freshCache();
reloaded.loadCache();
expect(reloaded.getCached("foo")).toBe("bar");
});

it("loadCache is a no-op when no cache file exists", async () => {
const fs = await getFsMock();
const cache = await freshCache();
expect(() => cache.loadCache()).not.toThrow();
expect(fs.readFileSync).not.toHaveBeenCalled();
});

it("loadCache tolerates malformed JSON without throwing", async () => {
const fs = await getFsMock();
const probe = await freshCache();
probe.setCached("probe", 0);
const path = fs.writeFileSync.mock.calls.at(-1)![0];

fs.__store.clear();
fs.__store.set(path, "{ not json");

const reloaded = await freshCache();
expect(() => reloaded.loadCache()).not.toThrow();
expect(reloaded.getCached("anything")).toBeNull();
});
it("never loads the old JSON cache for live data", async () => {
vi.resetModules();
vi.stubEnv("DEMO", "0");
const cache = await import("./cache.js");
cache.loadCache();
expect(cache.getCached("github:prs")).toBeNull();
expect(fs.readFileSync).not.toHaveBeenCalled();
});

describe("cache in DEMO mode", () => {
it("setCached skips writing to disk", async () => {
vi.stubEnv("DEMO", "1");
const { setCached } = await freshCache();
const fs = await getFsMock();
setCached("k", "v");
expect(fs.writeFileSync).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
it("loads read-only demo fixtures", async () => {
vi.resetModules();
vi.stubEnv("DEMO", "1");
const cache = await import("./cache.js");
cache.loadCache();
expect(cache.getCached("github:prs")).toEqual([{ number: 1 }]);
});
74 changes: 11 additions & 63 deletions packages/server/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,23 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";

const DEMO = process.env.DEMO === "1";

function resolveCacheDir(): string {
if (DEMO) return resolve(import.meta.dirname, "../../../.cache-demo");
if (process.env.GHD_DATA_DIR)
return resolve(process.env.GHD_DATA_DIR, ".cache");
return resolve(import.meta.dirname, "../../../.cache");
}

const CACHE_DIR = resolveCacheDir();
const CACHE_FILE = resolve(CACHE_DIR, "data.json");

interface CacheEntry {
data: unknown;
updatedAt: string;
}

const store = new Map<string, CacheEntry>();

// Load from disk on startup
// Read-only demo fixtures. Live data is owned exclusively by the sync engine.
const store = new Map<string, { data: unknown }>();
export function loadCache() {
if (process.env.DEMO !== "1") return;
const path = resolve(import.meta.dirname, "../../../.cache-demo/data.json");
try {
if (existsSync(CACHE_FILE)) {
const raw = JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
for (const [key, entry] of Object.entries(raw)) {
if (!DEMO && /:(prs|reviews|notifications)$/.test(key)) continue;
store.set(key, entry as CacheEntry);
if (existsSync(path)) {
const entries = JSON.parse(readFileSync(path, "utf-8"));
for (const [key, entry] of Object.entries(entries)) {
store.set(key, entry as { data: unknown });
}
console.log(`Cache loaded: ${store.size} entries`);
}
} catch {
console.log("No existing cache found, starting fresh");
}
}

function persistCache() {
if (DEMO) return;
try {
mkdirSync(CACHE_DIR, { recursive: true });
const obj: Record<string, CacheEntry> = {};
for (const [key, entry] of store) {
obj[key] = entry;
}
writeFileSync(CACHE_FILE, JSON.stringify(obj));
} catch (err) {
console.error("Failed to persist cache:", err);
console.log("Could not load demo fixtures");
}
}

export function getCached<T>(key: string): T | null {
const entry = store.get(key);
if (!entry) return null;
return entry.data as T;
}

export function setCached(key: string, data: unknown) {
store.set(key, { data, updatedAt: new Date().toISOString() });
persistCache();
}

export function cacheAge(key: string): number | null {
const entry = store.get(key);
if (!entry) return null;
return Date.now() - new Date(entry.updatedAt).getTime();
}

/** Ancillary REST caches only; persistent lists live in the shared engine. */
export function clearCache() {
if (DEMO) return;
store.clear();
persistCache();
return (store.get(key)?.data as T) ?? null;
}
12 changes: 9 additions & 3 deletions packages/server/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ const octokitHolder = vi.hoisted(
}),
);

vi.mock("@octokit/rest", async () => {
vi.mock("sync", async () => {
const { octokitStub } = await import("./test-utils/octokit-mock.js");
octokitHolder.ctor = octokitStub({ login: "alice" });
return {
get Octokit() {
return octokitHolder.ctor;
authenticate: async (instance: { token: string; baseUrl: string }) => {
const client = new octokitHolder.ctor({
auth: instance.token,
baseUrl: instance.baseUrl,
}) as {
users: { getAuthenticated: () => Promise<{ data: { login: string } }> };
};
return (await client.users.getAuthenticated()).data.login;
},
};
});
Expand Down
12 changes: 8 additions & 4 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { Octokit } from "@octokit/rest";
import { authenticate } from "sync";
import { parse } from "yaml";
import { z } from "zod";

Expand Down Expand Up @@ -215,9 +215,13 @@ async function probeInstance(
baseUrl: string,
): Promise<ProbeResult> {
try {
const client = new Octokit({ auth: token, baseUrl });
const { data } = await client.users.getAuthenticated();
return { ok: true, username: data.login };
const username = await authenticate({
id: baseUrl,
label: baseUrl,
token,
baseUrl,
});
return { ok: true, username };
} catch (err) {
return classifyProbeError(err);
}
Expand Down
Loading
Loading