Skip to content

Commit 5a7d96c

Browse files
dcramerclaude
andcommitted
refactor(sync): Replace direct GitHubSyncService with SyncRegistry
- Add SyncRegistry class to manage multiple sync services - Update TaskService to use syncRegistry instead of syncService - Rename syncToGitHub() to syncToIntegrations() with parallel sync support - Add integrations container to TaskMetadataSchema for multi-provider support - Add id/displayName properties to GitHubSyncService for registry compatibility - Update bootstrap.ts with createSyncRegistry() factory function - Update CLI, MCP server, and index.ts to use new registry pattern - Update tests to use SyncRegistry with mock services This enables parallel sync to multiple integrations (GitHub, GitLab, Linear, etc.) while maintaining backward compatibility with existing GitHub sync. Refs #96 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 491cf59 commit 5a7d96c

File tree

10 files changed

+258
-97
lines changed

10 files changed

+258
-97
lines changed

src/bootstrap.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import type { GitHubSyncConfig } from "./core/config.js";
1+
import type { SyncConfig } from "./core/config.js";
22
import { loadConfig } from "./core/config.js";
33
import type { StorageEngine } from "./core/storage/index.js";
44
import { JsonlStorage } from "./core/storage/index.js";
5-
import {
6-
createGitHubSyncService,
7-
GitHubSyncService,
8-
} from "./core/github/index.js";
5+
import { SyncRegistry } from "./core/sync/index.js";
6+
import { createGitHubSyncService } from "./core/github/index.js";
97

108
export interface ParsedGlobalOptions {
119
storagePath?: string;
@@ -99,23 +97,40 @@ export function createStorageEngine(
9997
});
10098
}
10199

102-
export interface SyncServiceResult {
103-
syncService: GitHubSyncService | null;
104-
syncConfig: GitHubSyncConfig | null;
100+
export interface SyncRegistryResult {
101+
syncRegistry: SyncRegistry | null;
102+
syncConfig: SyncConfig | null;
105103
}
106104

107-
export function createSyncService(
105+
/**
106+
* Create a SyncRegistry from configuration.
107+
* Registers all enabled sync services based on config.
108+
*/
109+
export function createSyncRegistry(
108110
storagePath: string,
109111
cliConfigPath?: string,
110-
): SyncServiceResult {
112+
): SyncRegistryResult {
111113
const config = loadConfig({ storagePath, configPath: cliConfigPath });
112-
const githubConfig = config.sync?.github ?? null;
114+
const syncConfig = config.sync ?? null;
115+
116+
const registry = new SyncRegistry();
117+
118+
// Register GitHub sync service if configured
119+
const githubService = createGitHubSyncService(
120+
syncConfig?.github ?? undefined,
121+
storagePath,
122+
);
123+
if (githubService) {
124+
registry.register(githubService);
125+
}
126+
127+
// Future: Register other sync services here
128+
// if (syncConfig?.gitlab) { registry.register(createGitlabSyncService(...)); }
129+
// if (syncConfig?.linear) { registry.register(createLinearSyncService(...)); }
130+
113131
return {
114-
syncService: createGitHubSyncService(
115-
githubConfig ?? undefined,
116-
storagePath,
117-
),
118-
syncConfig: githubConfig,
132+
syncRegistry: registry.hasServices() ? registry : null,
133+
syncConfig,
119134
};
120135
}
121136

src/cli/utils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { TaskService } from "../core/task-service.js";
22
import type { StorageEngine } from "../core/storage/index.js";
3-
import { GitHubSyncService } from "../core/github/index.js";
4-
import type { GitHubSyncConfig } from "../core/config.js";
3+
import type { SyncRegistry } from "../core/sync/index.js";
4+
import type { SyncConfig } from "../core/config.js";
55
import type { Task } from "../types.js";
66
import { extractErrorInfo } from "../errors.js";
77
import * as readline from "readline";
88
import { colors } from "./colors.js";
99

1010
export interface CliOptions {
1111
storage: StorageEngine;
12-
syncService?: GitHubSyncService | null;
13-
syncConfig?: GitHubSyncConfig | null;
12+
syncRegistry?: SyncRegistry | null;
13+
syncConfig?: SyncConfig | null;
1414
}
1515

1616
// ASCII art banner for CLI headers
@@ -22,7 +22,7 @@ export const ASCII_BANNER = ` ____ _____ __ __
2222
export function createService(options: CliOptions): TaskService {
2323
return new TaskService({
2424
storage: options.storage,
25-
syncService: options.syncService,
25+
syncRegistry: options.syncRegistry,
2626
syncConfig: options.syncConfig,
2727
});
2828
}

src/core/github/sync.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export interface SyncAllOptions {
8282
* before code changes are pushed.
8383
*/
8484
export class GitHubSyncService {
85+
/** Integration ID for the SyncRegistry */
86+
readonly id = "github" as const;
87+
/** Human-readable name for display */
88+
readonly displayName = "GitHub";
89+
8590
private octokit: Octokit;
8691
private owner: string;
8792
private repo: string;

src/core/sync/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ export type {
66
SyncAllOptions,
77
SyncService,
88
} from "./interface.js";
9+
10+
export { SyncRegistry } from "./registry.js";
11+
export type { RegisterableSyncService, LegacySyncResult } from "./registry.js";

src/core/sync/registry.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Task, TaskStore } from "../../types.js";
2+
import type { IntegrationId, SyncAllOptions } from "./interface.js";
3+
4+
/**
5+
* Result from a sync operation - loose type to support both
6+
* new interface format (metadata) and legacy GitHub format (github).
7+
*/
8+
export interface LegacySyncResult {
9+
taskId: string;
10+
created: boolean;
11+
skipped?: boolean;
12+
metadata?: unknown;
13+
github?: unknown;
14+
}
15+
16+
/**
17+
* A sync service that can be registered.
18+
* This is a looser type than SyncService to allow legacy services
19+
* that don't fully implement the new interface yet.
20+
*/
21+
export interface RegisterableSyncService {
22+
readonly id: IntegrationId;
23+
readonly displayName: string;
24+
syncTask(task: Task, store: TaskStore): Promise<LegacySyncResult | null>;
25+
syncAll(
26+
store: TaskStore,
27+
options?: SyncAllOptions,
28+
): Promise<LegacySyncResult[]>;
29+
}
30+
31+
/**
32+
* Registry for managing multiple sync services.
33+
* Allows registering and retrieving sync services by integration ID.
34+
*/
35+
export class SyncRegistry {
36+
private services: Map<IntegrationId, RegisterableSyncService> = new Map();
37+
38+
/**
39+
* Register a sync service.
40+
* Replaces any existing service with the same ID.
41+
*/
42+
register(service: RegisterableSyncService): void {
43+
this.services.set(service.id, service);
44+
}
45+
46+
/**
47+
* Get a sync service by its integration ID.
48+
*/
49+
get(id: IntegrationId): RegisterableSyncService | undefined {
50+
return this.services.get(id);
51+
}
52+
53+
/**
54+
* Get all registered sync services.
55+
*/
56+
getAll(): RegisterableSyncService[] {
57+
return Array.from(this.services.values());
58+
}
59+
60+
/**
61+
* Check if any services are registered.
62+
*/
63+
hasServices(): boolean {
64+
return this.services.size > 0;
65+
}
66+
}

src/core/task-service.test.ts

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import * as fs from "node:fs";
33
import * as path from "node:path";
44
import * as os from "node:os";
55
import { TaskService } from "./task-service.js";
6-
import { GitHubSyncService } from "./github/index.js";
6+
import { SyncRegistry } from "./sync/index.js";
7+
import type { RegisterableSyncService } from "./sync/index.js";
78
import { ValidationError } from "../errors.js";
89

910
describe("TaskService", () => {
@@ -886,21 +887,24 @@ describe("TaskService", () => {
886887
});
887888

888889
describe("autosync", () => {
889-
let mockSyncService: {
890-
syncTask: ReturnType<typeof vi.fn>;
891-
getRepo: ReturnType<typeof vi.fn>;
892-
};
890+
let mockSyncTask: ReturnType<typeof vi.fn>;
891+
let mockRegistry: SyncRegistry;
893892

894893
beforeEach(() => {
895-
mockSyncService = {
896-
syncTask: vi.fn(),
897-
getRepo: vi.fn(() => ({ owner: "test", repo: "test" })),
898-
};
894+
mockSyncTask = vi.fn();
895+
const mockSyncService = {
896+
id: "github" as const,
897+
displayName: "GitHub",
898+
syncTask: mockSyncTask,
899+
syncAll: vi.fn().mockResolvedValue([]),
900+
} as unknown as RegisterableSyncService;
901+
mockRegistry = new SyncRegistry();
902+
mockRegistry.register(mockSyncService);
899903
});
900904

901905
it("saves GitHub metadata when autosync creates an issue", async () => {
902906
// Mock syncTask to return the task ID it was called with
903-
mockSyncService.syncTask.mockImplementation(async (task) => ({
907+
mockSyncTask.mockImplementation(async (task) => ({
904908
taskId: task.id,
905909
github: {
906910
issueNumber: 42,
@@ -913,16 +917,16 @@ describe("TaskService", () => {
913917

914918
const syncService = new TaskService({
915919
storage: storagePath,
916-
syncService: mockSyncService as unknown as GitHubSyncService,
917-
syncConfig: { enabled: true, auto: { on_change: true } },
920+
syncRegistry: mockRegistry,
921+
syncConfig: { github: { enabled: true, auto: { on_change: true } } },
918922
});
919923

920924
const task = await syncService.create({
921925
name: "Test task",
922926
description: "Test",
923927
});
924928

925-
expect(mockSyncService.syncTask).toHaveBeenCalled();
929+
expect(mockSyncTask).toHaveBeenCalled();
926930

927931
// Fetch the task from storage to verify metadata was saved
928932
const savedTask = await syncService.get(task.id);
@@ -943,7 +947,7 @@ describe("TaskService", () => {
943947
});
944948

945949
// Now create a service with sync enabled
946-
mockSyncService.syncTask.mockResolvedValue({
950+
mockSyncTask.mockResolvedValue({
947951
taskId: task.id,
948952
github: {
949953
issueNumber: 99,
@@ -956,8 +960,8 @@ describe("TaskService", () => {
956960

957961
const syncService = new TaskService({
958962
storage: storagePath,
959-
syncService: mockSyncService as unknown as GitHubSyncService,
960-
syncConfig: { enabled: true, auto: { on_change: true } },
963+
syncRegistry: mockRegistry,
964+
syncConfig: { github: { enabled: true, auto: { on_change: true } } },
961965
});
962966

963967
await syncService.update({
@@ -984,7 +988,7 @@ describe("TaskService", () => {
984988
});
985989

986990
// Now update with sync enabled
987-
mockSyncService.syncTask.mockResolvedValue({
991+
mockSyncTask.mockResolvedValue({
988992
taskId: task.id,
989993
github: {
990994
issueNumber: 55,
@@ -997,8 +1001,8 @@ describe("TaskService", () => {
9971001

9981002
const syncService = new TaskService({
9991003
storage: storagePath,
1000-
syncService: mockSyncService as unknown as GitHubSyncService,
1001-
syncConfig: { enabled: true, auto: { on_change: true } },
1004+
syncRegistry: mockRegistry,
1005+
syncConfig: { github: { enabled: true, auto: { on_change: true } } },
10021006
});
10031007

10041008
await syncService.update({
@@ -1026,7 +1030,7 @@ describe("TaskService", () => {
10261030
await new Promise((resolve) => setTimeout(resolve, 10));
10271031

10281032
// Sync returns skipped: true
1029-
mockSyncService.syncTask.mockResolvedValue({
1033+
mockSyncTask.mockResolvedValue({
10301034
taskId: task.id,
10311035
github: {
10321036
issueNumber: 77,
@@ -1040,8 +1044,8 @@ describe("TaskService", () => {
10401044

10411045
const syncService = new TaskService({
10421046
storage: storagePath,
1043-
syncService: mockSyncService as unknown as GitHubSyncService,
1044-
syncConfig: { enabled: true, auto: { on_change: true } },
1047+
syncRegistry: mockRegistry,
1048+
syncConfig: { github: { enabled: true, auto: { on_change: true } } },
10451049
});
10461050

10471051
await syncService.update({
@@ -1068,7 +1072,7 @@ describe("TaskService", () => {
10681072
});
10691073

10701074
// Sync service returns parent's ID (subtasks sync their parent)
1071-
mockSyncService.syncTask.mockResolvedValue({
1075+
mockSyncTask.mockResolvedValue({
10721076
taskId: parent.id, // Parent gets the GitHub issue, not subtask
10731077
github: {
10741078
issueNumber: 88,
@@ -1081,8 +1085,8 @@ describe("TaskService", () => {
10811085

10821086
const syncService = new TaskService({
10831087
storage: storagePath,
1084-
syncService: mockSyncService as unknown as GitHubSyncService,
1085-
syncConfig: { enabled: true, auto: { on_change: true } },
1088+
syncRegistry: mockRegistry,
1089+
syncConfig: { github: { enabled: true, auto: { on_change: true } } },
10861090
});
10871091

10881092
// Update subtask triggers sync

0 commit comments

Comments
 (0)