Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/desktop-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,15 @@ 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
# changes, so creating one straight as pre-release breaks the
# 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
Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
AntonNiklasson marked this conversation as resolved.
},
"devDependencies": {
Expand All @@ -27,6 +28,7 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"electron"
]
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@github-dashboard/desktop",
"name": "desktop",
"productName": "GitHub Dashboard",
"private": true,
"version": "0.0.2",
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@github-dashboard/server",
"name": "server",
"private": true,
"type": "module",
"scripts": {
Expand Down
33 changes: 33 additions & 0 deletions packages/sync/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
84 changes: 84 additions & 0 deletions packages/sync/src/cache/open.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions packages/sync/src/cache/open.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
18 changes: 18 additions & 0 deletions packages/sync/src/cache/path.ts
Original file line number Diff line number Diff line change
@@ -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");
}
73 changes: 73 additions & 0 deletions packages/sync/src/cache/schema.ts
Original file line number Diff line number Diff line change
@@ -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
);
`;
Loading
Loading