@sudocode-ai/local-server 0.1.13 → 0.1.15
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/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/public/assets/index-D_Cq2kbL.css +1 -0
- package/dist/public/assets/index-vAra91Pu.js +917 -0
- package/dist/public/assets/{index-3qSRtUlY.js.map → index-vAra91Pu.js.map} +1 -1
- package/dist/public/index.html +2 -2
- package/dist/routes/executions.d.ts.map +1 -1
- package/dist/routes/executions.js +89 -0
- package/dist/routes/executions.js.map +1 -1
- package/dist/routes/feedback.d.ts.map +1 -1
- package/dist/routes/feedback.js +5 -4
- package/dist/routes/feedback.js.map +1 -1
- package/dist/routes/import.d.ts +142 -0
- package/dist/routes/import.d.ts.map +1 -0
- package/dist/routes/import.js +896 -0
- package/dist/routes/import.js.map +1 -0
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +102 -0
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/plugins.d.ts +7 -0
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +110 -16
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/specs.d.ts.map +1 -1
- package/dist/routes/specs.js +133 -5
- package/dist/routes/specs.js.map +1 -1
- package/dist/services/execution-changes-service.d.ts.map +1 -1
- package/dist/services/execution-changes-service.js +17 -7
- package/dist/services/execution-changes-service.js.map +1 -1
- package/dist/services/external-refresh-service.d.ts +104 -0
- package/dist/services/external-refresh-service.d.ts.map +1 -0
- package/dist/services/external-refresh-service.js +520 -0
- package/dist/services/external-refresh-service.js.map +1 -0
- package/dist/services/repo-info.d.ts +4 -0
- package/dist/services/repo-info.d.ts.map +1 -1
- package/dist/services/repo-info.js +42 -5
- package/dist/services/repo-info.js.map +1 -1
- package/package.json +3 -3
- package/dist/public/assets/index-3qSRtUlY.js +0 -844
- 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
|