@sudocode-ai/local-server 0.1.14 → 0.1.16

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.
Files changed (74) hide show
  1. package/dist/execution/adapters/claude-adapter.d.ts.map +1 -1
  2. package/dist/execution/adapters/claude-adapter.js +1 -0
  3. package/dist/execution/adapters/claude-adapter.js.map +1 -1
  4. package/dist/execution/executors/agent-executor-wrapper.d.ts +1 -0
  5. package/dist/execution/executors/agent-executor-wrapper.d.ts.map +1 -1
  6. package/dist/execution/executors/agent-executor-wrapper.js +2 -0
  7. package/dist/execution/executors/agent-executor-wrapper.js.map +1 -1
  8. package/dist/execution/process/builders/claude.d.ts.map +1 -1
  9. package/dist/execution/process/builders/claude.js +1 -0
  10. package/dist/execution/process/builders/claude.js.map +1 -1
  11. package/dist/execution/worktree/manager.d.ts +19 -0
  12. package/dist/execution/worktree/manager.d.ts.map +1 -1
  13. package/dist/execution/worktree/manager.js +50 -0
  14. package/dist/execution/worktree/manager.js.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/public/assets/index-D4AKx6EO.css +1 -0
  19. package/dist/public/assets/index-DorQqwGV.js +927 -0
  20. package/dist/public/assets/{index-3qSRtUlY.js.map → index-DorQqwGV.js.map} +1 -1
  21. package/dist/public/assets/{react-vendor-5f1Wq1qs.js → react-vendor-DoNwlLhy.js} +2 -2
  22. package/dist/public/assets/{react-vendor-5f1Wq1qs.js.map → react-vendor-DoNwlLhy.js.map} +1 -1
  23. package/dist/public/assets/{ui-vendor-BuU3mq4G.js → ui-vendor-DlxvBun1.js} +2 -2
  24. package/dist/public/assets/{ui-vendor-BuU3mq4G.js.map → ui-vendor-DlxvBun1.js.map} +1 -1
  25. package/dist/public/index.html +4 -4
  26. package/dist/routes/executions.d.ts.map +1 -1
  27. package/dist/routes/executions.js +89 -0
  28. package/dist/routes/executions.js.map +1 -1
  29. package/dist/routes/feedback.d.ts.map +1 -1
  30. package/dist/routes/feedback.js +5 -4
  31. package/dist/routes/feedback.js.map +1 -1
  32. package/dist/routes/import.d.ts +142 -0
  33. package/dist/routes/import.d.ts.map +1 -0
  34. package/dist/routes/import.js +896 -0
  35. package/dist/routes/import.js.map +1 -0
  36. package/dist/routes/issues.d.ts.map +1 -1
  37. package/dist/routes/issues.js +102 -0
  38. package/dist/routes/issues.js.map +1 -1
  39. package/dist/routes/plugins.d.ts +7 -0
  40. package/dist/routes/plugins.d.ts.map +1 -1
  41. package/dist/routes/plugins.js +110 -16
  42. package/dist/routes/plugins.js.map +1 -1
  43. package/dist/routes/projects.d.ts.map +1 -1
  44. package/dist/routes/projects.js +89 -0
  45. package/dist/routes/projects.js.map +1 -1
  46. package/dist/routes/specs.d.ts.map +1 -1
  47. package/dist/routes/specs.js +133 -5
  48. package/dist/routes/specs.js.map +1 -1
  49. package/dist/routes/update.d.ts +7 -0
  50. package/dist/routes/update.d.ts.map +1 -0
  51. package/dist/routes/update.js +194 -0
  52. package/dist/routes/update.js.map +1 -0
  53. package/dist/services/execution-changes-service.d.ts.map +1 -1
  54. package/dist/services/execution-changes-service.js +17 -7
  55. package/dist/services/execution-changes-service.js.map +1 -1
  56. package/dist/services/execution-service.d.ts +41 -0
  57. package/dist/services/execution-service.d.ts.map +1 -1
  58. package/dist/services/execution-service.js +312 -40
  59. package/dist/services/execution-service.js.map +1 -1
  60. package/dist/services/external-refresh-service.d.ts +104 -0
  61. package/dist/services/external-refresh-service.d.ts.map +1 -0
  62. package/dist/services/external-refresh-service.js +520 -0
  63. package/dist/services/external-refresh-service.js.map +1 -0
  64. package/dist/services/repo-info.d.ts +4 -0
  65. package/dist/services/repo-info.d.ts.map +1 -1
  66. package/dist/services/repo-info.js +42 -5
  67. package/dist/services/repo-info.js.map +1 -1
  68. package/dist/utils/execFileNoThrow.d.ts +43 -0
  69. package/dist/utils/execFileNoThrow.d.ts.map +1 -0
  70. package/dist/utils/execFileNoThrow.js +53 -0
  71. package/dist/utils/execFileNoThrow.js.map +1 -0
  72. package/package.json +6 -4
  73. package/dist/public/assets/index-3qSRtUlY.js +0 -844
  74. package/dist/public/assets/index-p0337DGd.css +0 -1
@@ -0,0 +1,896 @@
1
+ /**
2
+ * Server routes for on-demand import functionality
3
+ *
4
+ * Provides API endpoints for importing entities from external systems
5
+ * into sudocode specs via URL-based on-demand import.
6
+ */
7
+ import { Router } from "express";
8
+ import { createHash } from "crypto";
9
+ import { existsSync, readFileSync } from "fs";
10
+ import * as path from "path";
11
+ import { loadPlugin, getFirstPartyPlugins, testProviderConnection, } from "@sudocode-ai/cli/dist/integrations/index.js";
12
+ import { findSpecsByExternalLink, findIssuesByExternalLink, createSpecFromExternal, } from "@sudocode-ai/cli/dist/operations/external-links.js";
13
+ import { createIssue } from "@sudocode-ai/cli/dist/operations/issues.js";
14
+ import { createFeedback } from "@sudocode-ai/cli/dist/operations/feedback.js";
15
+ import { generateIssueId } from "@sudocode-ai/cli/dist/id-generator.js";
16
+ import { triggerExport, syncEntityToMarkdown } from "../services/export.js";
17
+ import { broadcastSpecUpdate } from "../services/websocket.js";
18
+ import { bulkRefresh, } from "../services/external-refresh-service.js";
19
+ /**
20
+ * Helper to read config.json
21
+ */
22
+ function readConfig(sudocodeDir) {
23
+ const configPath = path.join(sudocodeDir, "config.json");
24
+ if (!existsSync(configPath)) {
25
+ return {};
26
+ }
27
+ return JSON.parse(readFileSync(configPath, "utf-8"));
28
+ }
29
+ /**
30
+ * Compute SHA256 hash of content for change detection
31
+ */
32
+ function computeContentHash(title, content) {
33
+ const hash = createHash("sha256");
34
+ hash.update(title);
35
+ hash.update(content || "");
36
+ return hash.digest("hex");
37
+ }
38
+ /**
39
+ * Format a comment for import as IssueFeedback content
40
+ */
41
+ function formatImportedComment(comment) {
42
+ const dateStr = new Date(comment.created_at).toLocaleDateString("en-US", {
43
+ year: "numeric",
44
+ month: "short",
45
+ day: "numeric",
46
+ });
47
+ let content = `**@${comment.author}** commented on ${dateStr}:\n\n${comment.body}`;
48
+ if (comment.url) {
49
+ content += `\n\n---\n*Imported from [${comment.url}](${comment.url})*`;
50
+ }
51
+ return content;
52
+ }
53
+ /**
54
+ * Check if a provider supports on-demand import
55
+ */
56
+ function isOnDemandCapable(provider) {
57
+ return provider.supportsOnDemandImport === true;
58
+ }
59
+ /**
60
+ * Get URL patterns from a provider (if available)
61
+ */
62
+ function getProviderUrlPatterns(provider) {
63
+ // Try to get URL patterns from the provider
64
+ // Some providers may expose this information
65
+ if ("urlPatterns" in provider) {
66
+ return provider.urlPatterns;
67
+ }
68
+ // Default patterns based on known providers
69
+ const defaultPatterns = {
70
+ github: [
71
+ "https://github.com/{owner}/{repo}/issues/{number}",
72
+ "https://github.com/{owner}/{repo}/discussions/{number}",
73
+ ],
74
+ beads: ["beads://{workspace}/{id}"],
75
+ jira: ["https://{domain}.atlassian.net/browse/{key}"],
76
+ };
77
+ return defaultPatterns[provider.name] || [];
78
+ }
79
+ /**
80
+ * Determine auth method for a provider
81
+ */
82
+ function getProviderAuthMethod(providerName) {
83
+ // Known auth methods for providers
84
+ const authMethods = {
85
+ github: "gh-cli",
86
+ jira: "token",
87
+ beads: "none",
88
+ };
89
+ return authMethods[providerName] || "token";
90
+ }
91
+ export function createImportRouter() {
92
+ const router = Router();
93
+ /**
94
+ * GET /api/import/providers - List available import providers
95
+ *
96
+ * Returns all configured providers that support on-demand import
97
+ */
98
+ router.get("/providers", async (req, res) => {
99
+ try {
100
+ const firstPartyPlugins = getFirstPartyPlugins();
101
+ const config = readConfig(req.project.sudocodeDir);
102
+ const integrations = (config.integrations || {});
103
+ const providers = [];
104
+ // Check first-party plugins
105
+ for (const p of firstPartyPlugins) {
106
+ const providerConfig = integrations[p.name];
107
+ const plugin = await loadPlugin(p.name);
108
+ if (!plugin) {
109
+ continue;
110
+ }
111
+ // Create provider to check capabilities
112
+ try {
113
+ const provider = plugin.createProvider(providerConfig?.options || {}, req.project.path);
114
+ // Only include providers that support on-demand import
115
+ if (provider.supportsOnDemandImport) {
116
+ // Test if configured (has auth, etc.)
117
+ const testResult = await testProviderConnection(p.name, providerConfig || { enabled: false }, req.project.path);
118
+ providers.push({
119
+ name: p.name,
120
+ displayName: plugin.displayName,
121
+ supportsOnDemandImport: true,
122
+ supportsSearch: provider.supportsSearch,
123
+ urlPatterns: getProviderUrlPatterns(provider),
124
+ configured: testResult.configured,
125
+ authMethod: getProviderAuthMethod(p.name),
126
+ });
127
+ }
128
+ }
129
+ catch (error) {
130
+ // Provider creation failed - skip
131
+ console.warn(`[import] Failed to create provider ${p.name}:`, error);
132
+ }
133
+ }
134
+ // Also check custom plugins from config
135
+ const firstPartyNames = new Set(firstPartyPlugins.map((p) => p.name));
136
+ for (const [name, providerConfig] of Object.entries(integrations)) {
137
+ if (!firstPartyNames.has(name) && providerConfig) {
138
+ const pluginId = providerConfig.plugin || name;
139
+ const plugin = await loadPlugin(pluginId);
140
+ if (!plugin) {
141
+ continue;
142
+ }
143
+ try {
144
+ const provider = plugin.createProvider(providerConfig.options || {}, req.project.path);
145
+ if (provider.supportsOnDemandImport) {
146
+ const testResult = await testProviderConnection(name, providerConfig, req.project.path);
147
+ providers.push({
148
+ name,
149
+ displayName: plugin.displayName,
150
+ supportsOnDemandImport: true,
151
+ supportsSearch: provider.supportsSearch,
152
+ urlPatterns: getProviderUrlPatterns(provider),
153
+ configured: testResult.configured,
154
+ authMethod: getProviderAuthMethod(name),
155
+ });
156
+ }
157
+ }
158
+ catch (error) {
159
+ console.warn(`[import] Failed to create custom provider ${name}:`, error);
160
+ }
161
+ }
162
+ }
163
+ res.status(200).json({
164
+ success: true,
165
+ data: { providers },
166
+ });
167
+ }
168
+ catch (error) {
169
+ console.error("[import] Failed to list providers:", error);
170
+ res.status(500).json({
171
+ success: false,
172
+ error: "Failed to list import providers",
173
+ message: error instanceof Error ? error.message : String(error),
174
+ });
175
+ }
176
+ });
177
+ /**
178
+ * POST /api/import/preview - Preview an import before creating entity
179
+ *
180
+ * Fetches entity from external system and checks if already imported
181
+ */
182
+ router.post("/preview", async (req, res) => {
183
+ try {
184
+ const { url } = req.body;
185
+ if (!url || typeof url !== "string") {
186
+ res.status(400).json({
187
+ success: false,
188
+ error: "URL is required",
189
+ message: "Request body must include a valid URL string",
190
+ });
191
+ return;
192
+ }
193
+ // Get all enabled providers
194
+ const config = readConfig(req.project.sudocodeDir);
195
+ const integrations = (config.integrations || {});
196
+ const firstPartyPlugins = getFirstPartyPlugins();
197
+ // Find provider that can handle this URL
198
+ let matchedProvider = null;
199
+ let matchedProviderName = null;
200
+ // Check first-party plugins
201
+ for (const p of firstPartyPlugins) {
202
+ const plugin = await loadPlugin(p.name);
203
+ if (!plugin)
204
+ continue;
205
+ const providerConfig = integrations[p.name];
206
+ const provider = plugin.createProvider(providerConfig?.options || {}, req.project.path);
207
+ if (isOnDemandCapable(provider) && provider.canHandleUrl?.(url)) {
208
+ matchedProvider = provider;
209
+ matchedProviderName = p.name;
210
+ break;
211
+ }
212
+ }
213
+ // Check custom providers if no match
214
+ if (!matchedProvider) {
215
+ const firstPartyNames = new Set(firstPartyPlugins.map((p) => p.name));
216
+ for (const [name, providerConfig] of Object.entries(integrations)) {
217
+ if (!firstPartyNames.has(name) && providerConfig) {
218
+ const pluginId = providerConfig.plugin || name;
219
+ const plugin = await loadPlugin(pluginId);
220
+ if (!plugin)
221
+ continue;
222
+ const provider = plugin.createProvider(providerConfig.options || {}, req.project.path);
223
+ if (isOnDemandCapable(provider) && provider.canHandleUrl?.(url)) {
224
+ matchedProvider = provider;
225
+ matchedProviderName = name;
226
+ break;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ if (!matchedProvider || !matchedProviderName) {
232
+ res.status(422).json({
233
+ success: false,
234
+ error: "No provider found",
235
+ message: `No configured provider can handle URL: ${url}`,
236
+ });
237
+ return;
238
+ }
239
+ // Initialize provider if needed
240
+ await matchedProvider.initialize();
241
+ // Fetch entity by URL
242
+ const entity = isOnDemandCapable(matchedProvider)
243
+ ? await matchedProvider.fetchByUrl?.(url)
244
+ : null;
245
+ if (!entity) {
246
+ res.status(404).json({
247
+ success: false,
248
+ error: "Entity not found",
249
+ message: `Could not fetch entity from URL: ${url}`,
250
+ });
251
+ return;
252
+ }
253
+ // Check if already imported
254
+ let alreadyLinked;
255
+ const existingSpecs = findSpecsByExternalLink(req.project.sudocodeDir, matchedProviderName, entity.id);
256
+ if (existingSpecs.length > 0) {
257
+ const existingSpec = existingSpecs[0];
258
+ const link = existingSpec.external_links?.find((l) => l.provider === matchedProviderName && l.external_id === entity.id);
259
+ alreadyLinked = {
260
+ entityId: existingSpec.id,
261
+ entityType: "spec",
262
+ lastSyncedAt: link?.last_synced_at,
263
+ };
264
+ }
265
+ else {
266
+ // Also check issues
267
+ const existingIssues = findIssuesByExternalLink(req.project.sudocodeDir, matchedProviderName, entity.id);
268
+ if (existingIssues.length > 0) {
269
+ const existingIssue = existingIssues[0];
270
+ const link = existingIssue.external_links?.find((l) => l.provider === matchedProviderName && l.external_id === entity.id);
271
+ alreadyLinked = {
272
+ entityId: existingIssue.id,
273
+ entityType: "issue",
274
+ lastSyncedAt: link?.last_synced_at,
275
+ };
276
+ }
277
+ }
278
+ // Fetch comments count if supported
279
+ let commentsCount;
280
+ if (isOnDemandCapable(matchedProvider) && matchedProvider.fetchComments) {
281
+ try {
282
+ const comments = await matchedProvider.fetchComments(entity.id);
283
+ commentsCount = comments.length;
284
+ }
285
+ catch {
286
+ // Ignore errors fetching comments for preview
287
+ }
288
+ }
289
+ // Clean up provider
290
+ await matchedProvider.dispose();
291
+ const response = {
292
+ provider: matchedProviderName,
293
+ entity,
294
+ commentsCount,
295
+ alreadyLinked,
296
+ };
297
+ res.status(200).json({
298
+ success: true,
299
+ data: response,
300
+ });
301
+ }
302
+ catch (error) {
303
+ console.error("[import] Preview failed:", error);
304
+ // Handle specific error types
305
+ const errorMessage = error instanceof Error ? error.message : String(error);
306
+ if (errorMessage.includes("not authenticated") ||
307
+ errorMessage.includes("auth")) {
308
+ res.status(401).json({
309
+ success: false,
310
+ error: "Authentication required",
311
+ message: errorMessage,
312
+ });
313
+ return;
314
+ }
315
+ res.status(500).json({
316
+ success: false,
317
+ error: "Preview failed",
318
+ message: errorMessage,
319
+ });
320
+ }
321
+ });
322
+ /**
323
+ * POST /api/import/search - Search for entities in external systems
324
+ *
325
+ * Searches a provider for entities matching a query, or lists issues from a repo
326
+ */
327
+ router.post("/search", async (req, res) => {
328
+ try {
329
+ const { provider: providerName, query, repo, page = 1, perPage = 20, } = req.body;
330
+ if (!providerName || typeof providerName !== "string") {
331
+ res.status(400).json({
332
+ success: false,
333
+ error: "Provider is required",
334
+ message: "Request body must include a valid provider name",
335
+ });
336
+ return;
337
+ }
338
+ // Either query or repo must be provided
339
+ if (!query && !repo) {
340
+ res.status(400).json({
341
+ success: false,
342
+ error: "Query or repo is required",
343
+ message: "Request body must include a search query or repo to list issues from",
344
+ });
345
+ return;
346
+ }
347
+ // Get config
348
+ const config = readConfig(req.project.sudocodeDir);
349
+ const integrations = (config.integrations || {});
350
+ // Try to load the provider
351
+ const plugin = await loadPlugin(providerName);
352
+ if (!plugin) {
353
+ res.status(404).json({
354
+ success: false,
355
+ error: "Provider not found",
356
+ message: `Provider "${providerName}" is not installed`,
357
+ });
358
+ return;
359
+ }
360
+ const providerConfig = integrations[providerName];
361
+ const provider = plugin.createProvider(providerConfig?.options || {}, req.project.path);
362
+ // Check if provider supports search
363
+ if (!provider.supportsSearch) {
364
+ res.status(422).json({
365
+ success: false,
366
+ error: "Search not supported",
367
+ message: `Provider "${providerName}" does not support search`,
368
+ });
369
+ return;
370
+ }
371
+ // Initialize provider
372
+ await provider.initialize();
373
+ // Search for entities with options
374
+ const searchResult = await provider.searchEntities(query, {
375
+ repo,
376
+ page,
377
+ perPage: Math.min(perPage, 100),
378
+ });
379
+ // Clean up provider
380
+ await provider.dispose();
381
+ // Handle both old array format and new SearchResult format
382
+ const isSearchResult = searchResult &&
383
+ typeof searchResult === "object" &&
384
+ "results" in searchResult;
385
+ const results = isSearchResult
386
+ ? searchResult.results
387
+ : searchResult;
388
+ const pagination = isSearchResult
389
+ ? searchResult
390
+ .pagination
391
+ : undefined;
392
+ const response = {
393
+ provider: providerName,
394
+ query,
395
+ repo,
396
+ results,
397
+ pagination,
398
+ };
399
+ res.status(200).json({
400
+ success: true,
401
+ data: response,
402
+ });
403
+ }
404
+ catch (error) {
405
+ console.error("[import] Search failed:", error);
406
+ const errorMessage = error instanceof Error ? error.message : String(error);
407
+ if (errorMessage.includes("not authenticated") ||
408
+ errorMessage.includes("auth")) {
409
+ res.status(401).json({
410
+ success: false,
411
+ error: "Authentication required",
412
+ message: errorMessage,
413
+ });
414
+ return;
415
+ }
416
+ res.status(500).json({
417
+ success: false,
418
+ error: "Search failed",
419
+ message: errorMessage,
420
+ });
421
+ }
422
+ });
423
+ /**
424
+ * POST /api/import/batch - Batch import entities with upsert behavior
425
+ *
426
+ * Creates or updates specs from external entities. If an entity is already
427
+ * imported, it updates the existing spec instead of creating a duplicate.
428
+ */
429
+ router.post("/batch", async (req, res) => {
430
+ try {
431
+ const { provider: providerName, externalIds, options = {}, } = req.body;
432
+ // Validation
433
+ if (!providerName || typeof providerName !== "string") {
434
+ res.status(400).json({
435
+ success: false,
436
+ error: "Provider is required",
437
+ message: "Request body must include a valid provider name",
438
+ });
439
+ return;
440
+ }
441
+ if (!Array.isArray(externalIds) || externalIds.length === 0) {
442
+ res.status(400).json({
443
+ success: false,
444
+ error: "External IDs required",
445
+ message: "Request body must include a non-empty array of external IDs",
446
+ });
447
+ return;
448
+ }
449
+ // Get config
450
+ const config = readConfig(req.project.sudocodeDir);
451
+ const integrations = (config.integrations || {});
452
+ // Load the provider
453
+ const plugin = await loadPlugin(providerName);
454
+ if (!plugin) {
455
+ res.status(404).json({
456
+ success: false,
457
+ error: "Provider not found",
458
+ message: `Provider "${providerName}" is not installed`,
459
+ });
460
+ return;
461
+ }
462
+ const providerConfig = integrations[providerName];
463
+ const provider = plugin.createProvider(providerConfig?.options || {}, req.project.path);
464
+ // Check capabilities
465
+ if (!isOnDemandCapable(provider)) {
466
+ res.status(422).json({
467
+ success: false,
468
+ error: "Import not supported",
469
+ message: `Provider "${providerName}" does not support on-demand import`,
470
+ });
471
+ return;
472
+ }
473
+ // Initialize provider
474
+ await provider.initialize();
475
+ const results = [];
476
+ let created = 0;
477
+ let updated = 0;
478
+ let failed = 0;
479
+ // Process each external ID
480
+ for (const externalId of externalIds) {
481
+ try {
482
+ // Fetch the entity
483
+ const entity = await provider.fetchEntity(externalId);
484
+ if (!entity) {
485
+ results.push({
486
+ externalId,
487
+ success: false,
488
+ action: "failed",
489
+ error: "Entity not found",
490
+ });
491
+ failed++;
492
+ continue;
493
+ }
494
+ // Check if already imported
495
+ const existingSpecs = findSpecsByExternalLink(req.project.sudocodeDir, providerName, entity.id);
496
+ const now = new Date().toISOString();
497
+ if (existingSpecs.length > 0) {
498
+ // Update existing spec
499
+ const existingSpec = existingSpecs[0];
500
+ // Import updateSpec from CLI
501
+ const { updateSpec } = await import("@sudocode-ai/cli/dist/operations/specs.js");
502
+ const { updateSpecExternalLinkSync } = await import("@sudocode-ai/cli/dist/operations/external-links.js");
503
+ // Update the spec content
504
+ updateSpec(req.project.db, existingSpec.id, {
505
+ title: entity.title,
506
+ content: entity.description || "",
507
+ priority: options.priority ?? entity.priority ?? existingSpec.priority,
508
+ });
509
+ // Update the external link sync timestamp
510
+ updateSpecExternalLinkSync(req.project.sudocodeDir, existingSpec.id, entity.id, {
511
+ last_synced_at: now,
512
+ external_updated_at: entity.updated_at,
513
+ });
514
+ // Broadcast update
515
+ broadcastSpecUpdate(req.project.id, existingSpec.id, "updated", {
516
+ ...existingSpec,
517
+ title: entity.title,
518
+ content: entity.description || "",
519
+ });
520
+ results.push({
521
+ externalId,
522
+ success: true,
523
+ entityId: existingSpec.id,
524
+ action: "updated",
525
+ });
526
+ updated++;
527
+ }
528
+ else {
529
+ // Create new spec
530
+ const spec = createSpecFromExternal(req.project.sudocodeDir, {
531
+ title: entity.title,
532
+ content: entity.description || "",
533
+ priority: options.priority ?? entity.priority ?? 2,
534
+ external: {
535
+ provider: providerName,
536
+ external_id: entity.id,
537
+ sync_direction: "inbound",
538
+ },
539
+ relationships: entity.relationships?.map((r) => ({
540
+ targetExternalId: r.targetId,
541
+ targetType: r.targetType,
542
+ relationshipType: r.relationshipType,
543
+ })),
544
+ });
545
+ // Broadcast creation
546
+ broadcastSpecUpdate(req.project.id, spec.id, "created", spec);
547
+ results.push({
548
+ externalId,
549
+ success: true,
550
+ entityId: spec.id,
551
+ action: "created",
552
+ });
553
+ created++;
554
+ }
555
+ }
556
+ catch (itemError) {
557
+ const errorMessage = itemError instanceof Error ? itemError.message : String(itemError);
558
+ results.push({
559
+ externalId,
560
+ success: false,
561
+ action: "failed",
562
+ error: errorMessage,
563
+ });
564
+ failed++;
565
+ }
566
+ }
567
+ // Clean up provider
568
+ await provider.dispose();
569
+ // Trigger export if any changes were made
570
+ if (created > 0 || updated > 0) {
571
+ triggerExport(req.project.db, req.project.sudocodeDir);
572
+ }
573
+ const response = {
574
+ provider: providerName,
575
+ created,
576
+ updated,
577
+ failed,
578
+ results,
579
+ };
580
+ res.status(200).json({
581
+ success: true,
582
+ data: response,
583
+ });
584
+ }
585
+ catch (error) {
586
+ console.error("[import] Batch import failed:", error);
587
+ const errorMessage = error instanceof Error ? error.message : String(error);
588
+ if (errorMessage.includes("not authenticated") ||
589
+ errorMessage.includes("auth")) {
590
+ res.status(401).json({
591
+ success: false,
592
+ error: "Authentication required",
593
+ message: errorMessage,
594
+ });
595
+ return;
596
+ }
597
+ res.status(500).json({
598
+ success: false,
599
+ error: "Batch import failed",
600
+ message: errorMessage,
601
+ });
602
+ }
603
+ });
604
+ /**
605
+ * POST /api/import - Import entity and create spec
606
+ *
607
+ * Creates a spec with external_link from the given URL
608
+ */
609
+ router.post("/", async (req, res) => {
610
+ try {
611
+ const { url, options = {} } = req.body;
612
+ if (!url || typeof url !== "string") {
613
+ res.status(400).json({
614
+ success: false,
615
+ error: "URL is required",
616
+ message: "Request body must include a valid URL string",
617
+ });
618
+ return;
619
+ }
620
+ // Get all enabled providers
621
+ const config = readConfig(req.project.sudocodeDir);
622
+ const integrations = (config.integrations || {});
623
+ const firstPartyPlugins = getFirstPartyPlugins();
624
+ // Find provider that can handle this URL
625
+ let matchedProvider = null;
626
+ let matchedProviderName = null;
627
+ // Check first-party plugins
628
+ for (const p of firstPartyPlugins) {
629
+ const plugin = await loadPlugin(p.name);
630
+ if (!plugin)
631
+ continue;
632
+ const providerConfig = integrations[p.name];
633
+ const provider = plugin.createProvider(providerConfig?.options || {}, req.project.path);
634
+ if (isOnDemandCapable(provider) && provider.canHandleUrl?.(url)) {
635
+ matchedProvider = provider;
636
+ matchedProviderName = p.name;
637
+ break;
638
+ }
639
+ }
640
+ // Check custom providers if no match
641
+ if (!matchedProvider) {
642
+ const firstPartyNames = new Set(firstPartyPlugins.map((p) => p.name));
643
+ for (const [name, providerConfig] of Object.entries(integrations)) {
644
+ if (!firstPartyNames.has(name) && providerConfig) {
645
+ const pluginId = providerConfig.plugin || name;
646
+ const plugin = await loadPlugin(pluginId);
647
+ if (!plugin)
648
+ continue;
649
+ const provider = plugin.createProvider(providerConfig.options || {}, req.project.path);
650
+ if (isOnDemandCapable(provider) && provider.canHandleUrl?.(url)) {
651
+ matchedProvider = provider;
652
+ matchedProviderName = name;
653
+ break;
654
+ }
655
+ }
656
+ }
657
+ }
658
+ if (!matchedProvider || !matchedProviderName) {
659
+ res.status(422).json({
660
+ success: false,
661
+ error: "No provider found",
662
+ message: `No configured provider can handle URL: ${url}`,
663
+ });
664
+ return;
665
+ }
666
+ // Initialize provider
667
+ await matchedProvider.initialize();
668
+ // Fetch entity by URL
669
+ const entity = isOnDemandCapable(matchedProvider)
670
+ ? await matchedProvider.fetchByUrl?.(url)
671
+ : null;
672
+ if (!entity) {
673
+ res.status(404).json({
674
+ success: false,
675
+ error: "Entity not found",
676
+ message: `Could not fetch entity from URL: ${url}`,
677
+ });
678
+ return;
679
+ }
680
+ // Check if already imported
681
+ const existingSpecs = findSpecsByExternalLink(req.project.sudocodeDir, matchedProviderName, entity.id);
682
+ if (existingSpecs.length > 0) {
683
+ res.status(409).json({
684
+ success: false,
685
+ error: "Already imported",
686
+ message: `Entity already imported as spec: ${existingSpecs[0].id}`,
687
+ data: {
688
+ entityId: existingSpecs[0].id,
689
+ entityType: "spec",
690
+ },
691
+ });
692
+ return;
693
+ }
694
+ // Compute content hash
695
+ const contentHash = computeContentHash(entity.title, entity.description || "");
696
+ const now = new Date().toISOString();
697
+ // Create spec with external link
698
+ const spec = createSpecFromExternal(req.project.sudocodeDir, {
699
+ title: entity.title,
700
+ content: entity.description || "",
701
+ priority: options.priority ?? entity.priority ?? 2,
702
+ external: {
703
+ provider: matchedProviderName,
704
+ external_id: entity.id,
705
+ sync_direction: "inbound",
706
+ },
707
+ relationships: entity.relationships?.map((r) => ({
708
+ targetExternalId: r.targetId,
709
+ targetType: r.targetType,
710
+ relationshipType: r.relationshipType,
711
+ })),
712
+ });
713
+ // Update external_link with additional metadata
714
+ // Note: This is stored in JSONL, so we need to update there
715
+ const externalLink = {
716
+ provider: matchedProviderName,
717
+ external_id: entity.id,
718
+ external_url: entity.url,
719
+ sync_enabled: true,
720
+ sync_direction: "inbound",
721
+ last_synced_at: now,
722
+ external_updated_at: entity.updated_at,
723
+ content_hash: contentHash,
724
+ imported_at: now,
725
+ import_metadata: {
726
+ imported_by: "api",
727
+ original_status: entity.status,
728
+ original_type: entity.type,
729
+ },
730
+ };
731
+ // Trigger export to JSONL files
732
+ triggerExport(req.project.db, req.project.sudocodeDir);
733
+ // Sync to markdown file (fire and forget)
734
+ const syncPromise = syncEntityToMarkdown(req.project.db, spec.id, "spec", req.project.sudocodeDir);
735
+ if (syncPromise && typeof syncPromise.catch === "function") {
736
+ syncPromise.catch((error) => {
737
+ console.error(`[import] Failed to sync spec ${spec.id} to markdown:`, error);
738
+ });
739
+ }
740
+ // Import comments as IssueFeedback
741
+ let feedbackCount = 0;
742
+ if (options.includeComments &&
743
+ isOnDemandCapable(matchedProvider) &&
744
+ matchedProvider.fetchComments) {
745
+ try {
746
+ const comments = await matchedProvider.fetchComments(entity.id);
747
+ if (comments.length > 0) {
748
+ // Create a placeholder issue to serve as the feedback source
749
+ // TODO: Support feedback without an issue.
750
+ const { id: placeholderIssueId, uuid: placeholderIssueUuid } = generateIssueId(req.project.db, req.project.sudocodeDir);
751
+ const placeholderIssue = createIssue(req.project.db, {
752
+ id: placeholderIssueId,
753
+ uuid: placeholderIssueUuid,
754
+ title: `Imported comments for: ${entity.title}`,
755
+ content: `This issue was created to hold imported comments from [${entity.url}](${entity.url}).\n\n` +
756
+ `Provider: ${matchedProviderName}\n` +
757
+ `External ID: ${entity.id}\n` +
758
+ `Imported at: ${now}\n` +
759
+ `Comments: ${comments.length}`,
760
+ status: "closed",
761
+ priority: 4, // Lowest priority - placeholder only
762
+ });
763
+ console.log(`[import] Created placeholder issue ${placeholderIssue.id} for ${comments.length} comments`);
764
+ // Import each comment as IssueFeedback
765
+ for (const comment of comments) {
766
+ try {
767
+ createFeedback(req.project.db, {
768
+ from_id: placeholderIssue.id,
769
+ to_id: spec.id,
770
+ feedback_type: "comment",
771
+ content: formatImportedComment(comment),
772
+ agent: "import",
773
+ created_at: comment.created_at,
774
+ });
775
+ feedbackCount++;
776
+ }
777
+ catch (feedbackError) {
778
+ console.warn(`[import] Failed to create feedback for comment ${comment.id}:`, feedbackError);
779
+ }
780
+ }
781
+ console.log(`[import] Successfully imported ${feedbackCount} of ${comments.length} comments as feedback`);
782
+ }
783
+ }
784
+ catch (error) {
785
+ console.warn("[import] Failed to fetch/import comments:", error);
786
+ }
787
+ }
788
+ // Clean up provider
789
+ await matchedProvider.dispose();
790
+ // Broadcast spec creation
791
+ broadcastSpecUpdate(req.project.id, spec.id, "created", spec);
792
+ const response = {
793
+ entityId: spec.id,
794
+ entityType: "spec",
795
+ externalLink,
796
+ feedbackCount: feedbackCount > 0 ? feedbackCount : undefined,
797
+ };
798
+ res.status(201).json({
799
+ success: true,
800
+ data: response,
801
+ });
802
+ }
803
+ catch (error) {
804
+ console.error("[import] Import failed:", error);
805
+ // Handle specific error types
806
+ const errorMessage = error instanceof Error ? error.message : String(error);
807
+ if (errorMessage.includes("not authenticated") ||
808
+ errorMessage.includes("auth")) {
809
+ res.status(401).json({
810
+ success: false,
811
+ error: "Authentication required",
812
+ message: errorMessage,
813
+ });
814
+ return;
815
+ }
816
+ res.status(500).json({
817
+ success: false,
818
+ error: "Import failed",
819
+ message: errorMessage,
820
+ });
821
+ }
822
+ });
823
+ /**
824
+ * POST /api/import/refresh - Refresh multiple entities from external sources
825
+ *
826
+ * Request body:
827
+ * - provider?: string - Filter by provider name
828
+ * - entityIds?: string[] - Specific entity IDs to refresh
829
+ * - force?: boolean - Skip conflict check, overwrite local changes
830
+ *
831
+ * Response:
832
+ * - refreshed: number - Count of successfully refreshed entities
833
+ * - skipped: number - Count of skipped entities (no changes or local changes without force)
834
+ * - failed: number - Count of failed refreshes
835
+ * - stale: number - Count of stale links (external entity deleted)
836
+ * - results: Array<{entityId, status, error?}> - Per-entity results
837
+ */
838
+ router.post("/refresh", async (req, res) => {
839
+ try {
840
+ const { provider, entityIds, force } = req.body;
841
+ // Validate entityIds if provided
842
+ if (entityIds !== undefined) {
843
+ if (!Array.isArray(entityIds)) {
844
+ res.status(400).json({
845
+ success: false,
846
+ error: "Invalid request",
847
+ message: "entityIds must be an array of strings",
848
+ });
849
+ return;
850
+ }
851
+ if (!entityIds.every((id) => typeof id === "string")) {
852
+ res.status(400).json({
853
+ success: false,
854
+ error: "Invalid request",
855
+ message: "entityIds must contain only strings",
856
+ });
857
+ return;
858
+ }
859
+ }
860
+ // Validate provider if provided
861
+ if (provider !== undefined && typeof provider !== "string") {
862
+ res.status(400).json({
863
+ success: false,
864
+ error: "Invalid request",
865
+ message: "provider must be a string",
866
+ });
867
+ return;
868
+ }
869
+ // Execute bulk refresh
870
+ const result = await bulkRefresh(req.project.db, req.project.sudocodeDir, req.project.path, {
871
+ provider,
872
+ entityIds,
873
+ force: force === true,
874
+ });
875
+ // Trigger export if any entities were updated
876
+ if (result.refreshed > 0) {
877
+ triggerExport(req.project.db, req.project.sudocodeDir);
878
+ }
879
+ res.status(200).json({
880
+ success: true,
881
+ data: result,
882
+ });
883
+ }
884
+ catch (error) {
885
+ console.error("[import] Bulk refresh failed:", error);
886
+ const errorMessage = error instanceof Error ? error.message : String(error);
887
+ res.status(500).json({
888
+ success: false,
889
+ error: "Bulk refresh failed",
890
+ message: errorMessage,
891
+ });
892
+ }
893
+ });
894
+ return router;
895
+ }
896
+ //# sourceMappingURL=import.js.map