Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"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 server build && pnpm --filter web build",
"build": "pnpm --filter sync 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 .",
Expand Down
31 changes: 31 additions & 0 deletions packages/sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Sync service

`sync` exposes a small application API for consumers such as the dashboard
server. Cache files, SQLite rows, repositories, GitHub providers, and engine
construction are private implementation details.

```ts
import { createSyncService, type SyncService } from "sync";

const sync: SyncService = createSyncService({
onBackgroundError: console.error,
});

await sync.sync({ instanceId: "github-com", kind: "prs" });
const authored = sync.listAuthoredPullRequests("github-com");

sync.start({ intervalMs: 25_000 });
await sync.close();
```

## Contract

- Read methods return normalized domain objects, never database rows.
- `sync` is awaited and reports its cycle result to the caller.
- `requestSync` is fire-and-forget; failures go to `onBackgroundError` and are
contained by the service.
- `start` and `stop` control the recurring loop.
- `close` waits for the loop and requested syncs, then releases storage. It is
terminal and idempotent.
- Consumers depend on `SyncService`, so tests can provide an in-memory fake
without opening SQLite, reading config, starting timers, or calling GitHub.
31 changes: 20 additions & 11 deletions packages/sync/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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";
import { createSyncServiceFromDependencies } from "./service.js";

const USAGE = `ghd-sync — github-dashboard sync engine

Expand Down Expand Up @@ -69,16 +70,16 @@ async function onceCommand(args: string[]): Promise<number> {
return 1;
}

const { engine, close } = openEngine();
const service = openService();
try {
const summary = await engine.runOnce({
instance: values.instance,
const summary = await service.sync({
instanceId: values.instance,
kind,
});
printSummary(summary);
return 0;
} finally {
close();
await service.close();
}
}

Expand All @@ -105,8 +106,8 @@ async function loopCommand(args: string[]): Promise<number> {

const countdown = createCountdown(intervalMs);

const { engine, close } = openEngine();
engine.start({
const service = openService();
service.start({
intervalMs,
onCycle: (summary) => {
countdown.stop();
Expand All @@ -124,10 +125,10 @@ async function loopCommand(args: string[]): Promise<number> {
try {
await waitForSigint();
countdown.stop();
await engine.stop();
await service.stop();
return 0;
} finally {
close();
await service.close();
}
}

Expand Down Expand Up @@ -175,16 +176,24 @@ function createCountdown(intervalMs: number): {
};
}

function openEngine() {
function openService() {
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() };
return createSyncServiceFromDependencies({
repo,
engine: createSyncEngine({ repo }),
closeStorage: () => db.close(),
onBackgroundError: (error) => {
process.stderr.write(
`background sync failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
},
});
}

function statusCommand(): number {
Expand Down
65 changes: 15 additions & 50 deletions packages/sync/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,33 @@ 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";
import * as publicApi from "./index.js";
import { createSyncService, type SyncService } from "./index.js";

describe("public surface", () => {
let cacheRoot: string;
let prevXdg: string | undefined;
let previousXdg: string | undefined;
let service: SyncService | undefined;

beforeEach(() => {
cacheRoot = mkdtempSync(join(tmpdir(), "ghd-public-"));
prevXdg = process.env.XDG_CACHE_HOME;
previousXdg = 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;
afterEach(async () => {
await service?.close();
if (previousXdg === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = previousXdg;
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");
test("exposes the service boundary without storage or engine constructors", async () => {
expect(Object.keys(publicApi)).toEqual(["createSyncService"]);

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();
}
service = createSyncService();
expect(service.listAuthoredPullRequests("github-com")).toEqual([]);
expect(service.listReviewRequests("github-com")).toEqual([]);
expect(service.listNotifications("github-com")).toEqual([]);
});
});
68 changes: 14 additions & 54 deletions packages/sync/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,16 @@
// 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
// Public application boundary for embedding the sync engine. Storage,
// repositories, providers, and the engine itself are implementation details.
export type { Notification, PullRequest, ReviewSummary } from "./models.js";
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,
type CreateSyncServiceOptions,
type StartSyncOptions,
type SyncRequest,
type SyncService,
createSyncService,
} from "./service.js";
export type {
FetchSummary,
InstanceResult,
SyncCycleSummary,
SyncKind,
} from "./engine.js";
48 changes: 48 additions & 0 deletions packages/sync/src/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export type CiStatus = "success" | "failure" | "pending" | "unknown";

export interface ReviewSummary {
approved: string[];
changesRequested: string[];
}

export interface PullRequest {
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: string | null;
mergeStateStatus: string | null;
unresolvedThreadCount: number;
additions: number;
deletions: number;
commits: number;
commentCount: number;
labels: string[];
mergeable: boolean | null;
autoAssigned?: boolean;
}

export interface Notification {
id: string;
title: string;
type: string | null;
reason: string;
repo: string;
url: string;
unread: boolean;
updatedAt: string;
}
39 changes: 3 additions & 36 deletions packages/sync/src/providers/github/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PrNode } from "./queries.js";
import type { CiStatus, PullRequest, ReviewSummary } from "../../models.js";

export type CiStatus = "success" | "failure" | "pending" | "unknown";
export type { CiStatus, ReviewSummary } from "../../models.js";

export function mapCiStatus(state: string | null | undefined): CiStatus {
switch (state) {
Expand All @@ -23,11 +24,6 @@ export function mapMergeable(v: PrNode["mergeable"]): boolean | null {
return null;
}

export interface ReviewSummary {
approved: string[];
changesRequested: string[];
}

export function summarizeReviews(
reviews: PrNode["reviews"]["nodes"],
): ReviewSummary {
Expand All @@ -46,36 +42,7 @@ export function summarizeReviews(
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 type NormalizedPr = PullRequest;

export function normalizePr(node: PrNode): NormalizedPr {
const ci = mapCiStatus(
Expand Down
Loading
Loading