@sudocode-ai/cli 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/init-commands.d.ts.map +1 -1
- package/dist/cli/init-commands.js +6 -2
- package/dist/cli/init-commands.js.map +1 -1
- package/dist/cli/plugin-commands.d.ts +62 -0
- package/dist/cli/plugin-commands.d.ts.map +1 -0
- package/dist/cli/plugin-commands.js +595 -0
- package/dist/cli/plugin-commands.js.map +1 -0
- package/dist/cli.js +73 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -1
- package/dist/export.d.ts +10 -5
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +88 -18
- package/dist/export.js.map +1 -1
- package/dist/import.d.ts.map +1 -1
- package/dist/import.js +20 -0
- package/dist/import.js.map +1 -1
- package/dist/integrations/base-provider.d.ts +108 -0
- package/dist/integrations/base-provider.d.ts.map +1 -0
- package/dist/integrations/base-provider.js +80 -0
- package/dist/integrations/base-provider.js.map +1 -0
- package/dist/integrations/config-resolver.d.ts +62 -0
- package/dist/integrations/config-resolver.d.ts.map +1 -0
- package/dist/integrations/config-resolver.js +69 -0
- package/dist/integrations/config-resolver.js.map +1 -0
- package/dist/integrations/config-validator.d.ts +75 -0
- package/dist/integrations/config-validator.d.ts.map +1 -0
- package/dist/integrations/config-validator.js +129 -0
- package/dist/integrations/config-validator.js.map +1 -0
- package/dist/integrations/index.d.ts +14 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +20 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/integrations/plugin-loader.d.ts +69 -0
- package/dist/integrations/plugin-loader.d.ts.map +1 -0
- package/dist/integrations/plugin-loader.js +172 -0
- package/dist/integrations/plugin-loader.js.map +1 -0
- package/dist/integrations/registry.d.ts +67 -0
- package/dist/integrations/registry.d.ts.map +1 -0
- package/dist/integrations/registry.js +77 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/sync-coordinator.d.ts +186 -0
- package/dist/integrations/sync-coordinator.d.ts.map +1 -0
- package/dist/integrations/sync-coordinator.js +776 -0
- package/dist/integrations/sync-coordinator.js.map +1 -0
- package/dist/integrations/types.d.ts +142 -0
- package/dist/integrations/types.d.ts.map +1 -0
- package/dist/integrations/types.js +6 -0
- package/dist/integrations/types.js.map +1 -0
- package/dist/integrations/utils/conflict-resolver.d.ts +79 -0
- package/dist/integrations/utils/conflict-resolver.d.ts.map +1 -0
- package/dist/integrations/utils/conflict-resolver.js +106 -0
- package/dist/integrations/utils/conflict-resolver.js.map +1 -0
- package/dist/operations/external-links.d.ts +106 -0
- package/dist/operations/external-links.d.ts.map +1 -0
- package/dist/operations/external-links.js +300 -0
- package/dist/operations/external-links.js.map +1 -0
- package/dist/operations/feedback.d.ts.map +1 -1
- package/dist/operations/feedback.js +3 -1
- package/dist/operations/feedback.js.map +1 -1
- package/dist/operations/index.d.ts +1 -0
- package/dist/operations/index.d.ts.map +1 -1
- package/dist/operations/index.js +1 -0
- package/dist/operations/index.js.map +1 -1
- package/dist/operations/issues.d.ts +2 -0
- package/dist/operations/issues.d.ts.map +1 -1
- package/dist/operations/issues.js +34 -5
- package/dist/operations/issues.js.map +1 -1
- package/dist/operations/relationships.d.ts.map +1 -1
- package/dist/operations/relationships.js +6 -2
- package/dist/operations/relationships.js.map +1 -1
- package/dist/operations/specs.d.ts +2 -0
- package/dist/operations/specs.d.ts.map +1 -1
- package/dist/operations/specs.js +32 -4
- package/dist/operations/specs.js.map +1 -1
- package/dist/sync.d.ts +3 -3
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -0
- package/dist/sync.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -6
- package/dist/watcher.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncCoordinator - Orchestrates sync between sudocode and external integration providers
|
|
3
|
+
*
|
|
4
|
+
* Manages provider lifecycle, handles change detection, and resolves conflicts
|
|
5
|
+
* during bidirectional synchronization.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveByStrategy, logConflict } from "./utils/conflict-resolver.js";
|
|
8
|
+
import { readJSONLSync, writeJSONLSync } from "../jsonl.js";
|
|
9
|
+
import { findSpecsByExternalLink, findIssuesByExternalLink, getSpecFromJsonl, getIssueFromJsonl, updateSpecExternalLinkSync, updateIssueExternalLinkSync, createIssueFromExternal, deleteIssueFromJsonl, closeIssueInJsonl, removeExternalLinkFromIssue, } from "../operations/external-links.js";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
/**
|
|
12
|
+
* SyncCoordinator manages integration providers and synchronization
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const coordinator = new SyncCoordinator({
|
|
17
|
+
* projectPath: '/path/to/project',
|
|
18
|
+
* config: { jira: { enabled: true, ... } },
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* coordinator.registerProvider(new JiraProvider());
|
|
22
|
+
* await coordinator.start();
|
|
23
|
+
*
|
|
24
|
+
* // Sync all providers
|
|
25
|
+
* const results = await coordinator.syncAll();
|
|
26
|
+
*
|
|
27
|
+
* // Link a sudocode entity to an external entity
|
|
28
|
+
* await coordinator.linkEntity('i-abc', 'PROJ-123', 'jira');
|
|
29
|
+
*
|
|
30
|
+
* await coordinator.stop();
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class SyncCoordinator {
|
|
34
|
+
options;
|
|
35
|
+
providers = new Map();
|
|
36
|
+
lastSyncTimes = new Map();
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
}
|
|
40
|
+
// ==========================================================================
|
|
41
|
+
// Provider Management
|
|
42
|
+
// ==========================================================================
|
|
43
|
+
/**
|
|
44
|
+
* Register an integration provider
|
|
45
|
+
* @param provider - The provider to register
|
|
46
|
+
*/
|
|
47
|
+
registerProvider(provider) {
|
|
48
|
+
this.providers.set(provider.name, provider);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get a registered provider by name
|
|
52
|
+
* @param name - Provider name
|
|
53
|
+
* @returns The provider, or undefined if not found
|
|
54
|
+
*/
|
|
55
|
+
getProvider(name) {
|
|
56
|
+
return this.providers.get(name);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Get all registered provider names
|
|
60
|
+
*/
|
|
61
|
+
getProviderNames() {
|
|
62
|
+
return Array.from(this.providers.keys());
|
|
63
|
+
}
|
|
64
|
+
// ==========================================================================
|
|
65
|
+
// Lifecycle
|
|
66
|
+
// ==========================================================================
|
|
67
|
+
/**
|
|
68
|
+
* Start the coordinator and initialize all enabled providers
|
|
69
|
+
*
|
|
70
|
+
* For each registered provider:
|
|
71
|
+
* 1. Skip if not enabled in config
|
|
72
|
+
* 2. Initialize with config
|
|
73
|
+
* 3. Validate connection
|
|
74
|
+
* 4. Start watching if auto_sync is enabled and provider supports it
|
|
75
|
+
*/
|
|
76
|
+
async start() {
|
|
77
|
+
for (const [name, provider] of this.providers) {
|
|
78
|
+
const config = this.getProviderConfig(name);
|
|
79
|
+
if (!config?.enabled)
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
await provider.initialize(config);
|
|
83
|
+
const validation = await provider.validate();
|
|
84
|
+
if (!validation.valid) {
|
|
85
|
+
console.warn(`[sync-coordinator] Provider ${name} validation failed:`, validation.errors);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Start watching if auto_sync is enabled and provider supports it
|
|
89
|
+
if (config.auto_sync &&
|
|
90
|
+
provider.supportsWatch &&
|
|
91
|
+
provider.startWatching) {
|
|
92
|
+
provider.startWatching((changes) => {
|
|
93
|
+
this.handleInboundChanges(name, changes);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
this.lastSyncTimes.set(name, new Date());
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
console.error(`[sync-coordinator] Failed to initialize provider ${name}:`, error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Stop the coordinator and dispose all providers
|
|
105
|
+
*/
|
|
106
|
+
async stop() {
|
|
107
|
+
for (const [_name, provider] of this.providers) {
|
|
108
|
+
try {
|
|
109
|
+
provider.stopWatching?.();
|
|
110
|
+
await provider.dispose();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
console.error(`Error disposing provider ${_name}:`, error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ==========================================================================
|
|
118
|
+
// Sync Operations
|
|
119
|
+
// ==========================================================================
|
|
120
|
+
/**
|
|
121
|
+
* Sync all registered and enabled providers
|
|
122
|
+
* @returns Array of sync results from all providers
|
|
123
|
+
*/
|
|
124
|
+
async syncAll() {
|
|
125
|
+
const results = [];
|
|
126
|
+
for (const name of this.providers.keys()) {
|
|
127
|
+
const config = this.getProviderConfig(name);
|
|
128
|
+
if (!config?.enabled)
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const providerResults = await this.syncProvider(name);
|
|
132
|
+
results.push(...providerResults);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
results.push({
|
|
136
|
+
success: false,
|
|
137
|
+
entity_id: "",
|
|
138
|
+
external_id: "",
|
|
139
|
+
action: "skipped",
|
|
140
|
+
error: `Provider ${name} sync failed: ${String(error)}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Sync a specific provider
|
|
148
|
+
* @param providerName - Name of the provider to sync
|
|
149
|
+
* @returns Array of sync results
|
|
150
|
+
*/
|
|
151
|
+
async syncProvider(providerName) {
|
|
152
|
+
const provider = this.providers.get(providerName);
|
|
153
|
+
if (!provider) {
|
|
154
|
+
throw new Error(`Unknown provider: ${providerName}`);
|
|
155
|
+
}
|
|
156
|
+
const lastSync = this.lastSyncTimes.get(providerName) || new Date(0);
|
|
157
|
+
const changes = await provider.getChangesSince(lastSync);
|
|
158
|
+
const results = await this.handleInboundChanges(providerName, changes);
|
|
159
|
+
this.lastSyncTimes.set(providerName, new Date());
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Sync a specific entity's external links
|
|
164
|
+
* @param entityId - Sudocode entity ID (s-xxxx or i-xxxx)
|
|
165
|
+
* @returns Array of sync results for each link
|
|
166
|
+
*/
|
|
167
|
+
async syncEntity(entityId) {
|
|
168
|
+
const entity = this.loadEntity(entityId);
|
|
169
|
+
if (!entity?.external_links?.length) {
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
success: true,
|
|
173
|
+
entity_id: entityId,
|
|
174
|
+
external_id: "",
|
|
175
|
+
action: "skipped",
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
const results = [];
|
|
180
|
+
for (const link of entity.external_links) {
|
|
181
|
+
if (!link.sync_enabled)
|
|
182
|
+
continue;
|
|
183
|
+
try {
|
|
184
|
+
const result = await this.syncSingleLink(entity, link);
|
|
185
|
+
results.push(result);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
results.push({
|
|
189
|
+
success: false,
|
|
190
|
+
entity_id: entityId,
|
|
191
|
+
external_id: link.external_id,
|
|
192
|
+
action: "skipped",
|
|
193
|
+
error: String(error),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
// ==========================================================================
|
|
200
|
+
// Link Management
|
|
201
|
+
// ==========================================================================
|
|
202
|
+
/**
|
|
203
|
+
* Link a sudocode entity to an external entity
|
|
204
|
+
*
|
|
205
|
+
* @param entityId - Sudocode entity ID
|
|
206
|
+
* @param externalId - External system entity ID
|
|
207
|
+
* @param provider - Provider name
|
|
208
|
+
* @param options - Link options (sync direction, enabled)
|
|
209
|
+
*/
|
|
210
|
+
async linkEntity(entityId, externalId, provider, options) {
|
|
211
|
+
const entity = this.loadEntity(entityId);
|
|
212
|
+
if (!entity) {
|
|
213
|
+
throw new Error(`Entity not found: ${entityId}`);
|
|
214
|
+
}
|
|
215
|
+
const link = {
|
|
216
|
+
provider: provider,
|
|
217
|
+
external_id: externalId,
|
|
218
|
+
sync_enabled: options?.sync_enabled ?? true,
|
|
219
|
+
sync_direction: options?.sync_direction ?? "bidirectional",
|
|
220
|
+
last_synced_at: new Date().toISOString(),
|
|
221
|
+
};
|
|
222
|
+
const links = entity.external_links || [];
|
|
223
|
+
// Check if link already exists
|
|
224
|
+
const existingIndex = links.findIndex((l) => l.provider === provider && l.external_id === externalId);
|
|
225
|
+
if (existingIndex >= 0) {
|
|
226
|
+
// Update existing link
|
|
227
|
+
links[existingIndex] = { ...links[existingIndex], ...link };
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Add new link
|
|
231
|
+
links.push(link);
|
|
232
|
+
}
|
|
233
|
+
this.updateEntityLinks(entityId, links);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Remove a link between a sudocode entity and an external entity
|
|
237
|
+
*
|
|
238
|
+
* @param entityId - Sudocode entity ID
|
|
239
|
+
* @param externalId - External system entity ID
|
|
240
|
+
*/
|
|
241
|
+
async unlinkEntity(entityId, externalId) {
|
|
242
|
+
const entity = this.loadEntity(entityId);
|
|
243
|
+
if (!entity?.external_links)
|
|
244
|
+
return;
|
|
245
|
+
const links = entity.external_links.filter((l) => l.external_id !== externalId);
|
|
246
|
+
this.updateEntityLinks(entityId, links);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Handle deletion of a sudocode entity - propagate to external systems
|
|
250
|
+
*
|
|
251
|
+
* Call this when a sudocode entity is deleted to propagate the deletion
|
|
252
|
+
* to all linked external systems.
|
|
253
|
+
*
|
|
254
|
+
* @param entityId - Sudocode entity ID that was deleted
|
|
255
|
+
* @param externalLinks - The external_links from the deleted entity
|
|
256
|
+
* @returns Array of sync results
|
|
257
|
+
*/
|
|
258
|
+
async handleEntityDeleted(entityId, externalLinks) {
|
|
259
|
+
const results = [];
|
|
260
|
+
if (!externalLinks || externalLinks.length === 0) {
|
|
261
|
+
return results;
|
|
262
|
+
}
|
|
263
|
+
for (const link of externalLinks) {
|
|
264
|
+
// Skip if sync is disabled or inbound-only
|
|
265
|
+
if (!link.sync_enabled)
|
|
266
|
+
continue;
|
|
267
|
+
if (link.sync_direction === "inbound")
|
|
268
|
+
continue;
|
|
269
|
+
const provider = this.providers.get(link.provider);
|
|
270
|
+
if (!provider)
|
|
271
|
+
continue;
|
|
272
|
+
const config = this.getProviderConfig(link.provider);
|
|
273
|
+
const deleteBehavior = config?.delete_behavior || "close";
|
|
274
|
+
try {
|
|
275
|
+
if (deleteBehavior === "delete" && provider.deleteEntity) {
|
|
276
|
+
await provider.deleteEntity(link.external_id);
|
|
277
|
+
results.push({
|
|
278
|
+
success: true,
|
|
279
|
+
entity_id: entityId,
|
|
280
|
+
external_id: link.external_id,
|
|
281
|
+
action: "updated",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
else if (deleteBehavior === "close" && provider.updateEntity) {
|
|
285
|
+
await provider.updateEntity(link.external_id, { status: "closed" });
|
|
286
|
+
results.push({
|
|
287
|
+
success: true,
|
|
288
|
+
entity_id: entityId,
|
|
289
|
+
external_id: link.external_id,
|
|
290
|
+
action: "updated",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
results.push({
|
|
295
|
+
success: true,
|
|
296
|
+
entity_id: entityId,
|
|
297
|
+
external_id: link.external_id,
|
|
298
|
+
action: "skipped",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
results.push({
|
|
304
|
+
success: false,
|
|
305
|
+
entity_id: entityId,
|
|
306
|
+
external_id: link.external_id,
|
|
307
|
+
action: "skipped",
|
|
308
|
+
error: String(error),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return results;
|
|
313
|
+
}
|
|
314
|
+
// ==========================================================================
|
|
315
|
+
// Internal Methods - Change Handling
|
|
316
|
+
// ==========================================================================
|
|
317
|
+
/**
|
|
318
|
+
* Handle inbound changes from an external provider
|
|
319
|
+
*/
|
|
320
|
+
async handleInboundChanges(providerName, changes) {
|
|
321
|
+
const results = [];
|
|
322
|
+
const provider = this.providers.get(providerName);
|
|
323
|
+
if (!provider)
|
|
324
|
+
return results;
|
|
325
|
+
for (const change of changes) {
|
|
326
|
+
try {
|
|
327
|
+
const result = await this.processInboundChange(providerName, provider, change);
|
|
328
|
+
results.push(result);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
results.push({
|
|
332
|
+
success: false,
|
|
333
|
+
entity_id: "",
|
|
334
|
+
external_id: change.entity_id,
|
|
335
|
+
action: "skipped",
|
|
336
|
+
error: String(error),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return results;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Process a single inbound change
|
|
344
|
+
*/
|
|
345
|
+
async processInboundChange(providerName, provider, change) {
|
|
346
|
+
const config = this.getProviderConfig(providerName);
|
|
347
|
+
const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
|
|
348
|
+
// Find linked sudocode entity
|
|
349
|
+
const linkedEntity = this.findLinkedEntity(providerName, change.entity_id);
|
|
350
|
+
// Handle NEW entities (auto-import)
|
|
351
|
+
if (!linkedEntity && change.change_type === "created") {
|
|
352
|
+
// Auto-import if enabled (defaults to true)
|
|
353
|
+
const autoImport = config?.auto_import !== false;
|
|
354
|
+
if (autoImport && change.data) {
|
|
355
|
+
// Map external entity to sudocode format
|
|
356
|
+
const mapped = provider.mapToSudocode(change.data);
|
|
357
|
+
const issueData = mapped.issue;
|
|
358
|
+
if (issueData && change.entity_type === "issue") {
|
|
359
|
+
// Create sudocode issue with auto-link
|
|
360
|
+
const newIssue = createIssueFromExternal(sudocodeDir, {
|
|
361
|
+
title: issueData.title || change.data.title,
|
|
362
|
+
content: issueData.content || change.data.description,
|
|
363
|
+
status: issueData.status || "open",
|
|
364
|
+
priority: issueData.priority ?? change.data.priority ?? 2,
|
|
365
|
+
external: {
|
|
366
|
+
provider: providerName,
|
|
367
|
+
external_id: change.entity_id,
|
|
368
|
+
sync_direction: config?.default_sync_direction || "bidirectional",
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
return {
|
|
372
|
+
success: true,
|
|
373
|
+
entity_id: newIssue.id,
|
|
374
|
+
external_id: change.entity_id,
|
|
375
|
+
action: "created",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
success: true,
|
|
381
|
+
entity_id: "",
|
|
382
|
+
external_id: change.entity_id,
|
|
383
|
+
action: "skipped",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// Handle UPDATED entities without link (skip)
|
|
387
|
+
if (!linkedEntity && change.change_type === "updated") {
|
|
388
|
+
return {
|
|
389
|
+
success: true,
|
|
390
|
+
entity_id: "",
|
|
391
|
+
external_id: change.entity_id,
|
|
392
|
+
action: "skipped",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// Handle DELETED entities
|
|
396
|
+
if (change.change_type === "deleted") {
|
|
397
|
+
if (!linkedEntity) {
|
|
398
|
+
return {
|
|
399
|
+
success: true,
|
|
400
|
+
entity_id: "",
|
|
401
|
+
external_id: change.entity_id,
|
|
402
|
+
action: "skipped",
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// Get delete behavior from config (defaults to 'close')
|
|
406
|
+
const deleteBehavior = config?.delete_behavior || "close";
|
|
407
|
+
if (deleteBehavior === "ignore") {
|
|
408
|
+
// Disable sync on the stale link to avoid future sync errors
|
|
409
|
+
updateIssueExternalLinkSync(sudocodeDir, linkedEntity.id, change.entity_id, {
|
|
410
|
+
sync_enabled: false,
|
|
411
|
+
});
|
|
412
|
+
return {
|
|
413
|
+
success: true,
|
|
414
|
+
entity_id: linkedEntity.id,
|
|
415
|
+
external_id: change.entity_id,
|
|
416
|
+
action: "skipped",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (deleteBehavior === "delete") {
|
|
420
|
+
deleteIssueFromJsonl(sudocodeDir, linkedEntity.id);
|
|
421
|
+
return {
|
|
422
|
+
success: true,
|
|
423
|
+
entity_id: linkedEntity.id,
|
|
424
|
+
external_id: change.entity_id,
|
|
425
|
+
action: "updated", // Using 'updated' as there's no 'deleted' action type
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Default: close the issue AND remove the stale external link
|
|
429
|
+
closeIssueInJsonl(sudocodeDir, linkedEntity.id);
|
|
430
|
+
// Clean up the external link to avoid orphaned reference
|
|
431
|
+
removeExternalLinkFromIssue(sudocodeDir, linkedEntity.id, change.entity_id);
|
|
432
|
+
return {
|
|
433
|
+
success: true,
|
|
434
|
+
entity_id: linkedEntity.id,
|
|
435
|
+
external_id: change.entity_id,
|
|
436
|
+
action: "updated",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// Check for conflicts
|
|
440
|
+
const link = linkedEntity?.external_links?.find((l) => l.external_id === change.entity_id);
|
|
441
|
+
if (link && change.data?.updated_at && linkedEntity) {
|
|
442
|
+
const conflict = this.detectConflict(linkedEntity, change, link);
|
|
443
|
+
if (conflict) {
|
|
444
|
+
const resolution = await this.resolveConflict(conflict);
|
|
445
|
+
if (resolution === "skip") {
|
|
446
|
+
return {
|
|
447
|
+
success: true,
|
|
448
|
+
entity_id: linkedEntity.id,
|
|
449
|
+
external_id: change.entity_id,
|
|
450
|
+
action: "skipped",
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
if (resolution === "sudocode") {
|
|
454
|
+
// Push sudocode version to external
|
|
455
|
+
await provider.updateEntity(change.entity_id, linkedEntity);
|
|
456
|
+
this.updateLinkSyncTime(linkedEntity.id, link.external_id);
|
|
457
|
+
return {
|
|
458
|
+
success: true,
|
|
459
|
+
entity_id: linkedEntity.id,
|
|
460
|
+
external_id: change.entity_id,
|
|
461
|
+
action: "updated",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Apply inbound change (external wins or no conflict)
|
|
467
|
+
if (change.data && linkedEntity) {
|
|
468
|
+
const mapped = provider.mapToSudocode(change.data);
|
|
469
|
+
const updates = mapped.spec || mapped.issue;
|
|
470
|
+
if (updates) {
|
|
471
|
+
this.applyUpdatesToEntity(linkedEntity.id, updates);
|
|
472
|
+
if (link) {
|
|
473
|
+
this.updateLinkSyncTime(linkedEntity.id, link.external_id);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
success: true,
|
|
477
|
+
entity_id: linkedEntity.id,
|
|
478
|
+
external_id: change.entity_id,
|
|
479
|
+
action: "updated",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
success: true,
|
|
485
|
+
entity_id: linkedEntity?.id || "",
|
|
486
|
+
external_id: change.entity_id,
|
|
487
|
+
action: "skipped",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Sync a single external link
|
|
492
|
+
*/
|
|
493
|
+
async syncSingleLink(entity, link) {
|
|
494
|
+
console.log(`[sync-coordinator] syncSingleLink: entity=${entity.id}, external=${link.external_id}, direction=${link.sync_direction}`);
|
|
495
|
+
const provider = this.providers.get(link.provider);
|
|
496
|
+
if (!provider) {
|
|
497
|
+
console.log(`[sync-coordinator] Provider not registered: ${link.provider}`);
|
|
498
|
+
return {
|
|
499
|
+
success: false,
|
|
500
|
+
entity_id: entity.id,
|
|
501
|
+
external_id: link.external_id,
|
|
502
|
+
action: "skipped",
|
|
503
|
+
error: `Provider not registered: ${link.provider}`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
// Fetch current external state
|
|
508
|
+
console.log(`[sync-coordinator] Fetching external entity ${link.external_id}`);
|
|
509
|
+
const externalEntity = await provider.fetchEntity(link.external_id);
|
|
510
|
+
if (!externalEntity) {
|
|
511
|
+
console.log(`[sync-coordinator] External entity not found: ${link.external_id}`);
|
|
512
|
+
return {
|
|
513
|
+
success: false,
|
|
514
|
+
entity_id: entity.id,
|
|
515
|
+
external_id: link.external_id,
|
|
516
|
+
action: "skipped",
|
|
517
|
+
error: "External entity not found",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
console.log(`[sync-coordinator] External entity found, status=${externalEntity.status}`);
|
|
521
|
+
// Check for conflicts
|
|
522
|
+
const change = {
|
|
523
|
+
entity_id: link.external_id,
|
|
524
|
+
entity_type: externalEntity.type,
|
|
525
|
+
change_type: "updated",
|
|
526
|
+
timestamp: externalEntity.updated_at || new Date().toISOString(),
|
|
527
|
+
data: externalEntity,
|
|
528
|
+
};
|
|
529
|
+
const conflict = this.detectConflict(entity, change, link);
|
|
530
|
+
console.log(`[sync-coordinator] Conflict detected: ${conflict !== null}`);
|
|
531
|
+
if (conflict) {
|
|
532
|
+
const resolution = await this.resolveConflict(conflict);
|
|
533
|
+
console.log(`[sync-coordinator] Conflict resolution: ${resolution}`);
|
|
534
|
+
if (resolution === "skip") {
|
|
535
|
+
return {
|
|
536
|
+
success: true,
|
|
537
|
+
entity_id: entity.id,
|
|
538
|
+
external_id: link.external_id,
|
|
539
|
+
action: "skipped",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (resolution === "sudocode") {
|
|
543
|
+
// Push sudocode to external
|
|
544
|
+
console.log(`[sync-coordinator] Pushing sudocode to external (conflict resolution), entity:`, JSON.stringify(entity));
|
|
545
|
+
await provider.updateEntity(link.external_id, entity);
|
|
546
|
+
this.updateLinkSyncTime(entity.id, link.external_id);
|
|
547
|
+
return {
|
|
548
|
+
success: true,
|
|
549
|
+
entity_id: entity.id,
|
|
550
|
+
external_id: link.external_id,
|
|
551
|
+
action: "updated",
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
// External wins - apply to sudocode
|
|
555
|
+
const mapped = provider.mapToSudocode(externalEntity);
|
|
556
|
+
const updates = mapped.spec || mapped.issue;
|
|
557
|
+
if (updates) {
|
|
558
|
+
this.applyUpdatesToEntity(entity.id, updates);
|
|
559
|
+
}
|
|
560
|
+
this.updateLinkSyncTime(entity.id, link.external_id);
|
|
561
|
+
return {
|
|
562
|
+
success: true,
|
|
563
|
+
entity_id: entity.id,
|
|
564
|
+
external_id: link.external_id,
|
|
565
|
+
action: "updated",
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// No conflict - sync based on direction
|
|
569
|
+
console.log(`[sync-coordinator] No conflict, checking direction: ${link.sync_direction}`);
|
|
570
|
+
if (link.sync_direction === "outbound" ||
|
|
571
|
+
link.sync_direction === "bidirectional") {
|
|
572
|
+
console.log(`[sync-coordinator] Calling provider.updateEntity with:`, JSON.stringify(entity));
|
|
573
|
+
await provider.updateEntity(link.external_id, entity);
|
|
574
|
+
console.log(`[sync-coordinator] provider.updateEntity completed`);
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
console.log(`[sync-coordinator] Skipping updateEntity - direction is ${link.sync_direction}`);
|
|
578
|
+
}
|
|
579
|
+
this.updateLinkSyncTime(entity.id, link.external_id);
|
|
580
|
+
return {
|
|
581
|
+
success: true,
|
|
582
|
+
entity_id: entity.id,
|
|
583
|
+
external_id: link.external_id,
|
|
584
|
+
action: "updated",
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
console.error(`[sync-coordinator] syncSingleLink error:`, error);
|
|
589
|
+
return {
|
|
590
|
+
success: false,
|
|
591
|
+
entity_id: entity.id,
|
|
592
|
+
external_id: link.external_id,
|
|
593
|
+
action: "skipped",
|
|
594
|
+
error: String(error),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// ==========================================================================
|
|
599
|
+
// Internal Methods - Conflict Detection and Resolution
|
|
600
|
+
// ==========================================================================
|
|
601
|
+
/**
|
|
602
|
+
* Detect if there's a conflict between sudocode and external versions
|
|
603
|
+
*/
|
|
604
|
+
detectConflict(entity, change, link) {
|
|
605
|
+
const sudocodeUpdated = new Date(entity.updated_at);
|
|
606
|
+
const externalUpdated = new Date(change.data?.updated_at || 0);
|
|
607
|
+
const lastSynced = link.last_synced_at
|
|
608
|
+
? new Date(link.last_synced_at)
|
|
609
|
+
: new Date(0);
|
|
610
|
+
// Conflict if both updated since last sync
|
|
611
|
+
if (sudocodeUpdated > lastSynced && externalUpdated > lastSynced) {
|
|
612
|
+
return {
|
|
613
|
+
sudocode_entity_id: entity.id,
|
|
614
|
+
external_id: change.entity_id,
|
|
615
|
+
provider: link.provider,
|
|
616
|
+
sudocode_updated_at: entity.updated_at,
|
|
617
|
+
external_updated_at: change.data?.updated_at || "",
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Resolve a sync conflict based on configured strategy
|
|
624
|
+
*/
|
|
625
|
+
async resolveConflict(conflict) {
|
|
626
|
+
const config = this.getProviderConfig(conflict.provider);
|
|
627
|
+
const strategy = config?.conflict_resolution || "newest-wins";
|
|
628
|
+
if (strategy === "manual" && this.options.onConflict) {
|
|
629
|
+
const resolution = await this.options.onConflict(conflict);
|
|
630
|
+
logConflict({
|
|
631
|
+
timestamp: new Date().toISOString(),
|
|
632
|
+
conflict,
|
|
633
|
+
resolution,
|
|
634
|
+
strategy,
|
|
635
|
+
});
|
|
636
|
+
return resolution;
|
|
637
|
+
}
|
|
638
|
+
const resolution = resolveByStrategy(conflict, strategy);
|
|
639
|
+
logConflict({
|
|
640
|
+
timestamp: new Date().toISOString(),
|
|
641
|
+
conflict,
|
|
642
|
+
resolution,
|
|
643
|
+
strategy,
|
|
644
|
+
});
|
|
645
|
+
return resolution;
|
|
646
|
+
}
|
|
647
|
+
// ==========================================================================
|
|
648
|
+
// Internal Methods - Entity Operations
|
|
649
|
+
// ==========================================================================
|
|
650
|
+
/**
|
|
651
|
+
* Get provider config from options
|
|
652
|
+
*/
|
|
653
|
+
getProviderConfig(name) {
|
|
654
|
+
return this.options.config[name];
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Determine if an ID is for a spec or issue
|
|
658
|
+
*/
|
|
659
|
+
getEntityType(id) {
|
|
660
|
+
return id.startsWith("s-") ? "spec" : "issue";
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Get the JSONL file path for an entity type
|
|
664
|
+
*/
|
|
665
|
+
getEntityFilePath(type) {
|
|
666
|
+
const sudocodePath = path.join(this.options.projectPath, ".sudocode");
|
|
667
|
+
return type === "spec"
|
|
668
|
+
? path.join(sudocodePath, "specs.jsonl")
|
|
669
|
+
: path.join(sudocodePath, "issues.jsonl");
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Load an entity from JSONL storage
|
|
673
|
+
*/
|
|
674
|
+
/**
|
|
675
|
+
* Load an entity from JSONL storage
|
|
676
|
+
*/
|
|
677
|
+
loadEntity(id) {
|
|
678
|
+
const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
|
|
679
|
+
let jsonlEntity = null;
|
|
680
|
+
if (id.startsWith("s-")) {
|
|
681
|
+
jsonlEntity = getSpecFromJsonl(sudocodeDir, id);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
jsonlEntity = getIssueFromJsonl(sudocodeDir, id);
|
|
685
|
+
}
|
|
686
|
+
if (!jsonlEntity)
|
|
687
|
+
return null;
|
|
688
|
+
return this.toEntity(jsonlEntity);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Convert JSONL entity to domain Entity
|
|
692
|
+
*/
|
|
693
|
+
toEntity(jsonl) {
|
|
694
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
695
|
+
const { relationships, tags, feedback, ...entityData } = jsonl;
|
|
696
|
+
return entityData;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Find a sudocode entity linked to an external entity
|
|
700
|
+
*/
|
|
701
|
+
findLinkedEntity(provider, externalId) {
|
|
702
|
+
const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
|
|
703
|
+
const providerName = provider;
|
|
704
|
+
console.log(`[sync-coordinator] findLinkedEntity: looking for provider=${providerName}, external_id=${externalId}`);
|
|
705
|
+
// Search specs
|
|
706
|
+
const specs = findSpecsByExternalLink(sudocodeDir, providerName, externalId);
|
|
707
|
+
if (specs.length > 0) {
|
|
708
|
+
console.log(`[sync-coordinator] findLinkedEntity: found spec ${specs[0].id}`);
|
|
709
|
+
return this.toEntity(specs[0]);
|
|
710
|
+
}
|
|
711
|
+
// Search issues
|
|
712
|
+
const issues = findIssuesByExternalLink(sudocodeDir, providerName, externalId);
|
|
713
|
+
if (issues.length > 0) {
|
|
714
|
+
console.log(`[sync-coordinator] findLinkedEntity: found issue ${issues[0].id}`);
|
|
715
|
+
return this.toEntity(issues[0]);
|
|
716
|
+
}
|
|
717
|
+
console.log(`[sync-coordinator] findLinkedEntity: no linked entity found`);
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Update external_links on an entity
|
|
722
|
+
*/
|
|
723
|
+
updateEntityLinks(id, links) {
|
|
724
|
+
const type = this.getEntityType(id);
|
|
725
|
+
const filePath = this.getEntityFilePath(type);
|
|
726
|
+
const entities = readJSONLSync(filePath);
|
|
727
|
+
const index = entities.findIndex((e) => e.id === id);
|
|
728
|
+
if (index >= 0) {
|
|
729
|
+
entities[index].external_links = links;
|
|
730
|
+
entities[index].updated_at = new Date().toISOString();
|
|
731
|
+
writeJSONLSync(filePath, entities);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Update the last_synced_at timestamp on a link
|
|
736
|
+
*/
|
|
737
|
+
/**
|
|
738
|
+
* Update the last_synced_at timestamp on a link
|
|
739
|
+
*/
|
|
740
|
+
updateLinkSyncTime(entityId, externalId) {
|
|
741
|
+
const sudocodeDir = path.join(this.options.projectPath, ".sudocode");
|
|
742
|
+
const updates = { last_synced_at: new Date().toISOString() };
|
|
743
|
+
try {
|
|
744
|
+
if (entityId.startsWith("s-")) {
|
|
745
|
+
updateSpecExternalLinkSync(sudocodeDir, entityId, externalId, updates);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
updateIssueExternalLinkSync(sudocodeDir, entityId, externalId, updates);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
// Ignore if entity/link not found (race condition)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Apply partial updates to an entity
|
|
757
|
+
*/
|
|
758
|
+
applyUpdatesToEntity(id, updates) {
|
|
759
|
+
const type = this.getEntityType(id);
|
|
760
|
+
const filePath = this.getEntityFilePath(type);
|
|
761
|
+
const entities = readJSONLSync(filePath);
|
|
762
|
+
const index = entities.findIndex((e) => e.id === id);
|
|
763
|
+
if (index >= 0) {
|
|
764
|
+
const current = entities[index];
|
|
765
|
+
// Apply updates while preserving relationships, tags, etc.
|
|
766
|
+
const { id: _id, uuid: _uuid, ...safeUpdates } = updates;
|
|
767
|
+
entities[index] = {
|
|
768
|
+
...current,
|
|
769
|
+
...safeUpdates,
|
|
770
|
+
updated_at: new Date().toISOString(),
|
|
771
|
+
};
|
|
772
|
+
writeJSONLSync(filePath, entities);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
//# sourceMappingURL=sync-coordinator.js.map
|