agentdev-webui 1.0.0

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 (39) hide show
  1. package/lib/agent-api.js +530 -0
  2. package/lib/auth.js +127 -0
  3. package/lib/config.js +53 -0
  4. package/lib/database.js +762 -0
  5. package/lib/device-flow.js +257 -0
  6. package/lib/email.js +420 -0
  7. package/lib/encryption.js +112 -0
  8. package/lib/github.js +339 -0
  9. package/lib/history.js +143 -0
  10. package/lib/pwa.js +107 -0
  11. package/lib/redis-logs.js +226 -0
  12. package/lib/routes.js +680 -0
  13. package/migrations/000_create_database.sql +33 -0
  14. package/migrations/001_create_agentdev_schema.sql +135 -0
  15. package/migrations/001_create_agentdev_schema.sql.old +100 -0
  16. package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
  17. package/migrations/002_add_github_token.sql +17 -0
  18. package/migrations/003_add_agent_logs_table.sql +23 -0
  19. package/migrations/004_remove_oauth_columns.sql +11 -0
  20. package/migrations/005_add_projects.sql +44 -0
  21. package/migrations/006_project_github_token.sql +7 -0
  22. package/migrations/007_project_repositories.sql +12 -0
  23. package/migrations/008_add_notifications.sql +20 -0
  24. package/migrations/009_unified_oauth.sql +153 -0
  25. package/migrations/README.md +97 -0
  26. package/package.json +37 -0
  27. package/public/css/styles.css +1140 -0
  28. package/public/device.html +384 -0
  29. package/public/docs.html +862 -0
  30. package/public/docs.md +697 -0
  31. package/public/favicon.svg +5 -0
  32. package/public/index.html +271 -0
  33. package/public/js/app.js +2379 -0
  34. package/public/login.html +224 -0
  35. package/public/profile.html +394 -0
  36. package/public/register.html +392 -0
  37. package/public/reset-password.html +349 -0
  38. package/public/verify-email.html +177 -0
  39. package/server.js +1450 -0
package/lib/routes.js ADDED
@@ -0,0 +1,680 @@
1
+ const agentApi = require('./agent-api');
2
+ const deviceFlow = require('./device-flow');
3
+ const db = require('./database');
4
+ const encryption = require('./encryption');
5
+ const auth = require('./auth');
6
+
7
+ /**
8
+ * Register all API routes
9
+ */
10
+ function registerRoutes(req, res) {
11
+ const url = req.url.split('?')[0];
12
+ const method = req.method;
13
+
14
+ // ============================================================================
15
+ // Agent API Routes (require JWT authentication)
16
+ // ============================================================================
17
+
18
+ if (url === '/api/agent/device/code' && method === 'POST') {
19
+ return agentApi.handleDeviceCodeRequest(req, res);
20
+ }
21
+
22
+ if (url === '/api/agent/device/token' && method === 'POST') {
23
+ return agentApi.handleDeviceTokenPoll(req, res);
24
+ }
25
+
26
+ if (url === '/api/agent/heartbeat' && method === 'POST') {
27
+ return agentApi.handleHeartbeat(req, res);
28
+ }
29
+
30
+ if (url === '/api/agent/work' && method === 'GET') {
31
+ return agentApi.handleWorkRequest(req, res);
32
+ }
33
+
34
+ if (url === '/api/agent/logs' && method === 'POST') {
35
+ return agentApi.handleLogUpload(req, res);
36
+ }
37
+
38
+ if (url === '/api/agent/complete' && method === 'POST') {
39
+ return agentApi.handleWorkComplete(req, res);
40
+ }
41
+
42
+ if (url === '/api/agent/oauth' && method === 'GET') {
43
+ return agentApi.handleOAuthRequest(req, res);
44
+ }
45
+
46
+ if (url === '/api/agent/ensure-ticket' && method === 'POST') {
47
+ return agentApi.handleEnsureTicket(req, res);
48
+ }
49
+
50
+ if (url === '/api/agent/should-stop' && method === 'GET') {
51
+ return agentApi.handleShouldStop(req, res);
52
+ }
53
+
54
+ // Project board configuration (no auth — agents need this during registration)
55
+ if (url === '/api/agent/project-config' && method === 'GET') {
56
+ const config = require('./config');
57
+ const urlObj = new URL(req.url, `http://${req.headers.host}`);
58
+ const projectId = urlObj.searchParams.get('project_id');
59
+
60
+ (async () => {
61
+ try {
62
+ let projectData = null;
63
+ if (projectId) {
64
+ projectData = await db.getProjectById(parseInt(projectId));
65
+ }
66
+ if (!projectData) {
67
+ // Try project 1 from DB, fallback to config
68
+ projectData = await db.getProjectById(1);
69
+ }
70
+
71
+ if (projectData) {
72
+ const statusOptions = typeof projectData.status_options === 'string'
73
+ ? JSON.parse(projectData.status_options)
74
+ : projectData.status_options;
75
+ res.writeHead(200, { 'Content-Type': 'application/json' });
76
+ res.end(JSON.stringify({
77
+ github_org: projectData.github_org,
78
+ project_id: projectData.github_project_id,
79
+ project_number: projectData.project_number,
80
+ status_field_id: projectData.status_field_id,
81
+ status_options: statusOptions
82
+ }));
83
+ } else {
84
+ // Fallback to hardcoded config
85
+ res.writeHead(200, { 'Content-Type': 'application/json' });
86
+ res.end(JSON.stringify({
87
+ github_org: config.GITHUB_ORG,
88
+ project_id: config.PROJECT_ID,
89
+ project_number: config.PROJECT_NUMBER,
90
+ status_field_id: config.STATUS_FIELD_ID,
91
+ status_options: config.STATUS_OPTIONS
92
+ }));
93
+ }
94
+ } catch (error) {
95
+ console.error('Project config error:', error);
96
+ // Fallback to hardcoded config
97
+ res.writeHead(200, { 'Content-Type': 'application/json' });
98
+ res.end(JSON.stringify({
99
+ github_org: config.GITHUB_ORG,
100
+ project_id: config.PROJECT_ID,
101
+ project_number: config.PROJECT_NUMBER,
102
+ status_field_id: config.STATUS_FIELD_ID,
103
+ status_options: config.STATUS_OPTIONS
104
+ }));
105
+ }
106
+ })();
107
+ return true;
108
+ }
109
+
110
+ // ============================================================================
111
+ // Device Authorization Routes (require user session)
112
+ // ============================================================================
113
+
114
+ if (url === '/api/device/lookup' && method === 'POST') {
115
+ if (!auth.isAuthenticated(req)) {
116
+ res.writeHead(401, { 'Content-Type': 'application/json' });
117
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
118
+ return true;
119
+ }
120
+
121
+ let body = '';
122
+ req.on('data', chunk => body += chunk);
123
+ req.on('end', async () => {
124
+ try {
125
+ const { user_code } = JSON.parse(body);
126
+
127
+ const deviceData = await deviceFlow.getDeviceCodeByUserCode(user_code);
128
+
129
+ if (!deviceData) {
130
+ res.writeHead(404, { 'Content-Type': 'application/json' });
131
+ res.end(JSON.stringify({ error: 'Invalid or expired code' }));
132
+ return;
133
+ }
134
+
135
+ res.writeHead(200, { 'Content-Type': 'application/json' });
136
+ res.end(JSON.stringify(deviceData));
137
+ } catch (error) {
138
+ console.error('Device lookup error:', error);
139
+ res.writeHead(500, { 'Content-Type': 'application/json' });
140
+ res.end(JSON.stringify({ error: error.message }));
141
+ }
142
+ });
143
+ return true;
144
+ }
145
+
146
+ if (url === '/api/device/approve' && method === 'POST') {
147
+ if (!auth.isAuthenticated(req)) {
148
+ res.writeHead(401, { 'Content-Type': 'application/json' });
149
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
150
+ return true;
151
+ }
152
+
153
+ let body = '';
154
+ req.on('data', chunk => body += chunk);
155
+ req.on('end', async () => {
156
+ try {
157
+ const { user_code } = JSON.parse(body);
158
+
159
+ // Get user ID from session
160
+ // For now, use default user ID 1 (would need proper user session management)
161
+ const userId = 1;
162
+
163
+ await deviceFlow.approveDeviceCode(user_code, userId);
164
+
165
+ res.writeHead(200, { 'Content-Type': 'application/json' });
166
+ res.end(JSON.stringify({ success: true }));
167
+ } catch (error) {
168
+ console.error('Device approve error:', error);
169
+ res.writeHead(500, { 'Content-Type': 'application/json' });
170
+ res.end(JSON.stringify({ error: error.message }));
171
+ }
172
+ });
173
+ return true;
174
+ }
175
+
176
+ if (url === '/api/device/deny' && method === 'POST') {
177
+ if (!auth.isAuthenticated(req)) {
178
+ res.writeHead(401, { 'Content-Type': 'application/json' });
179
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
180
+ return true;
181
+ }
182
+
183
+ let body = '';
184
+ req.on('data', chunk => body += chunk);
185
+ req.on('end', async () => {
186
+ try {
187
+ const { user_code } = JSON.parse(body);
188
+
189
+ await deviceFlow.denyDeviceCode(user_code);
190
+
191
+ res.writeHead(200, { 'Content-Type': 'application/json' });
192
+ res.end(JSON.stringify({ success: true }));
193
+ } catch (error) {
194
+ console.error('Device deny error:', error);
195
+ res.writeHead(500, { 'Content-Type': 'application/json' });
196
+ res.end(JSON.stringify({ error: error.message }));
197
+ }
198
+ });
199
+ return true;
200
+ }
201
+
202
+ // ============================================================================
203
+ // Profile Routes (require user session)
204
+ // ============================================================================
205
+
206
+ if (url === '/api/profile' && method === 'GET') {
207
+ if (!auth.isAuthenticated(req)) {
208
+ res.writeHead(401, { 'Content-Type': 'application/json' });
209
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
210
+ return true;
211
+ }
212
+
213
+ (async () => {
214
+ try {
215
+ // Get user from session (for now, use default user ID 1)
216
+ const userId = 1;
217
+ const user = await db.getUserById(userId);
218
+
219
+ if (!user) {
220
+ res.writeHead(404, { 'Content-Type': 'application/json' });
221
+ res.end(JSON.stringify({ error: 'User not found' }));
222
+ return;
223
+ }
224
+
225
+ // Get user's OAuth providers from unified table
226
+ const oauthProviders = await db.getUserOAuthProviders(userId);
227
+
228
+ res.writeHead(200, { 'Content-Type': 'application/json' });
229
+ res.end(JSON.stringify({
230
+ email: user.email,
231
+ max_agents: user.max_agents,
232
+ oauth_providers: oauthProviders.map(p => ({
233
+ provider_key: p.provider_key,
234
+ provider_name: p.provider_name,
235
+ status: p.status,
236
+ scopes: p.scopes,
237
+ has_token: !!p.access_token
238
+ })),
239
+ has_github_token: oauthProviders.some(p => p.provider_key === 'github' && p.access_token) || !!user.github_token_encrypted
240
+ }));
241
+ } catch (error) {
242
+ console.error('Profile get error:', error);
243
+ res.writeHead(500, { 'Content-Type': 'application/json' });
244
+ res.end(JSON.stringify({ error: error.message }));
245
+ }
246
+ })();
247
+ return true;
248
+ }
249
+
250
+ if (url === '/api/profile/limits' && method === 'POST') {
251
+ if (!auth.isAuthenticated(req)) {
252
+ res.writeHead(401, { 'Content-Type': 'application/json' });
253
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
254
+ return true;
255
+ }
256
+
257
+ let body = '';
258
+ req.on('data', chunk => body += chunk);
259
+ req.on('end', async () => {
260
+ try {
261
+ const { max_agents } = JSON.parse(body);
262
+
263
+ // Update user (for now, use default user ID 1)
264
+ const userId = 1;
265
+ await db.query(
266
+ 'UPDATE agentdev_users SET max_agents = $1 WHERE id = $2',
267
+ [max_agents, userId]
268
+ );
269
+
270
+ res.writeHead(200, { 'Content-Type': 'application/json' });
271
+ res.end(JSON.stringify({ success: true }));
272
+ } catch (error) {
273
+ console.error('Limits update error:', error);
274
+ res.writeHead(500, { 'Content-Type': 'application/json' });
275
+ res.end(JSON.stringify({ error: error.message }));
276
+ }
277
+ });
278
+ return true;
279
+ }
280
+
281
+ if (url === '/api/agents' && method === 'GET') {
282
+ if (!auth.isAuthenticated(req)) {
283
+ res.writeHead(401, { 'Content-Type': 'application/json' });
284
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
285
+ return true;
286
+ }
287
+
288
+ (async () => {
289
+ try {
290
+ // Get user agents (for now, use default user ID 1)
291
+ const userId = 1;
292
+ const agents = await db.getAgentsByUser(userId);
293
+
294
+ res.writeHead(200, { 'Content-Type': 'application/json' });
295
+ res.end(JSON.stringify(agents));
296
+ } catch (error) {
297
+ console.error('Agents list error:', error);
298
+ res.writeHead(500, { 'Content-Type': 'application/json' });
299
+ res.end(JSON.stringify({ error: error.message }));
300
+ }
301
+ })();
302
+ return true;
303
+ }
304
+
305
+ // ============================================================================
306
+ // Project Routes (require user session)
307
+ // ============================================================================
308
+
309
+ if (url === '/api/projects' && method === 'GET') {
310
+ if (!auth.isAuthenticated(req)) {
311
+ res.writeHead(401, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
313
+ return true;
314
+ }
315
+
316
+ (async () => {
317
+ try {
318
+ const projects = await db.getProjects();
319
+ res.writeHead(200, { 'Content-Type': 'application/json' });
320
+ res.end(JSON.stringify(projects));
321
+ } catch (error) {
322
+ console.error('Projects list error:', error);
323
+ res.writeHead(500, { 'Content-Type': 'application/json' });
324
+ res.end(JSON.stringify({ error: error.message }));
325
+ }
326
+ })();
327
+ return true;
328
+ }
329
+
330
+ if (url === '/api/projects' && method === 'POST') {
331
+ if (!auth.isAuthenticated(req)) {
332
+ res.writeHead(401, { 'Content-Type': 'application/json' });
333
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
334
+ return true;
335
+ }
336
+
337
+ let body = '';
338
+ req.on('data', chunk => body += chunk);
339
+ req.on('end', async () => {
340
+ try {
341
+ const data = JSON.parse(body);
342
+ if (!data.name || !data.github_org || !data.project_number || !data.github_project_id || !data.status_field_id) {
343
+ res.writeHead(400, { 'Content-Type': 'application/json' });
344
+ res.end(JSON.stringify({ error: 'Missing required fields: name, github_org, project_number, github_project_id, status_field_id' }));
345
+ return;
346
+ }
347
+
348
+ // If save_token_globally, save the token to unified OAuth table
349
+ if (data.save_token_globally && data.github_token) {
350
+ const sessionId = auth.getSessionFromRequest(req);
351
+ const session = auth.getSession(sessionId);
352
+ if (session?.userId) {
353
+ const encryptedToken = encryption.encrypt(data.github_token);
354
+ // Save to unified OAuth providers table
355
+ const githubConfig = await db.getOAuthProviderConfig('github');
356
+ if (githubConfig) {
357
+ await db.upsertOAuthProvider({
358
+ userId: session.userId,
359
+ configId: githubConfig.id,
360
+ providerKey: 'github',
361
+ accessToken: encryptedToken,
362
+ scopes: ['repo', 'read:org', 'project']
363
+ });
364
+ console.log(`Saved GitHub token to unified OAuth table for user ${session.userId}`);
365
+ }
366
+ // Also save to legacy column for backward compatibility
367
+ await db.updateUserGitHubToken(session.userId, encryptedToken);
368
+ }
369
+ }
370
+ delete data.save_token_globally;
371
+
372
+ const project = await db.createProject(data);
373
+ // Don't leak token back to frontend
374
+ const { github_token: _token, ...safeProject } = project;
375
+ res.writeHead(201, { 'Content-Type': 'application/json' });
376
+ res.end(JSON.stringify(safeProject));
377
+ } catch (error) {
378
+ console.error('Project create error:', error);
379
+ res.writeHead(500, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ error: error.message }));
381
+ }
382
+ });
383
+ return true;
384
+ }
385
+
386
+ // PUT /api/projects/:id
387
+ const projectUpdateMatch = url.match(/^\/api\/projects\/(\d+)$/);
388
+ if (projectUpdateMatch && method === 'PUT') {
389
+ if (!auth.isAuthenticated(req)) {
390
+ res.writeHead(401, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
392
+ return true;
393
+ }
394
+
395
+ const projectId = parseInt(projectUpdateMatch[1]);
396
+ let body = '';
397
+ req.on('data', chunk => body += chunk);
398
+ req.on('end', async () => {
399
+ try {
400
+ const data = JSON.parse(body);
401
+ const project = await db.updateProject(projectId, data);
402
+ if (!project) {
403
+ res.writeHead(404, { 'Content-Type': 'application/json' });
404
+ res.end(JSON.stringify({ error: 'Project not found' }));
405
+ return;
406
+ }
407
+ res.writeHead(200, { 'Content-Type': 'application/json' });
408
+ res.end(JSON.stringify(project));
409
+ } catch (error) {
410
+ console.error('Project update error:', error);
411
+ res.writeHead(500, { 'Content-Type': 'application/json' });
412
+ res.end(JSON.stringify({ error: error.message }));
413
+ }
414
+ });
415
+ return true;
416
+ }
417
+
418
+ // DELETE /api/projects/:id
419
+ const projectDeleteMatch = url.match(/^\/api\/projects\/(\d+)$/);
420
+ if (projectDeleteMatch && method === 'DELETE') {
421
+ if (!auth.isAuthenticated(req)) {
422
+ res.writeHead(401, { 'Content-Type': 'application/json' });
423
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
424
+ return true;
425
+ }
426
+
427
+ const projectId = parseInt(projectDeleteMatch[1]);
428
+ (async () => {
429
+ try {
430
+ const project = await db.deleteProject(projectId);
431
+ if (!project) {
432
+ res.writeHead(404, { 'Content-Type': 'application/json' });
433
+ res.end(JSON.stringify({ error: 'Project not found' }));
434
+ return;
435
+ }
436
+ res.writeHead(200, { 'Content-Type': 'application/json' });
437
+ res.end(JSON.stringify({ success: true }));
438
+ } catch (error) {
439
+ console.error('Project delete error:', error);
440
+ res.writeHead(500, { 'Content-Type': 'application/json' });
441
+ res.end(JSON.stringify({ error: error.message }));
442
+ }
443
+ })();
444
+ return true;
445
+ }
446
+
447
+ // POST /api/agents/:id/project — assign agent to project
448
+ const agentProjectMatch = url.match(/^\/api\/agents\/([^/]+)\/project$/);
449
+ if (agentProjectMatch && method === 'POST') {
450
+ if (!auth.isAuthenticated(req)) {
451
+ res.writeHead(401, { 'Content-Type': 'application/json' });
452
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
453
+ return true;
454
+ }
455
+
456
+ const agentId = agentProjectMatch[1];
457
+ let body = '';
458
+ req.on('data', chunk => body += chunk);
459
+ req.on('end', async () => {
460
+ try {
461
+ const { project_id } = JSON.parse(body);
462
+ const agent = await db.assignAgentToProject(agentId, project_id);
463
+ if (!agent) {
464
+ res.writeHead(404, { 'Content-Type': 'application/json' });
465
+ res.end(JSON.stringify({ error: 'Agent not found' }));
466
+ return;
467
+ }
468
+ res.writeHead(200, { 'Content-Type': 'application/json' });
469
+ res.end(JSON.stringify(agent));
470
+ } catch (error) {
471
+ console.error('Agent project assign error:', error);
472
+ res.writeHead(500, { 'Content-Type': 'application/json' });
473
+ res.end(JSON.stringify({ error: error.message }));
474
+ }
475
+ });
476
+ return true;
477
+ }
478
+
479
+ // GET /api/github/repos?org=<org>&token=<optional> — fetch repos from a GitHub org
480
+ if (url === '/api/github/repos' && method === 'GET') {
481
+ if (!auth.isAuthenticated(req)) {
482
+ res.writeHead(401, { 'Content-Type': 'application/json' });
483
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
484
+ return true;
485
+ }
486
+
487
+ const urlObj = new URL(req.url, `http://${req.headers.host}`);
488
+ const org = urlObj.searchParams.get('org');
489
+ const projectToken = urlObj.searchParams.get('token') || null;
490
+
491
+ if (!org) {
492
+ res.writeHead(400, { 'Content-Type': 'application/json' });
493
+ res.end(JSON.stringify({ error: 'org parameter is required' }));
494
+ return true;
495
+ }
496
+
497
+ (async () => {
498
+ try {
499
+ // Resolve token: provided project token > unified OAuth table > legacy column > env default
500
+ let token = projectToken;
501
+ if (!token) {
502
+ const sessionId = auth.getSessionFromRequest(req);
503
+ const session = auth.getSession(sessionId);
504
+ if (session?.userId) {
505
+ const oauthProvider = await db.getOAuthProvider(session.userId, 'github');
506
+ if (oauthProvider?.access_token) {
507
+ token = encryption.decrypt(oauthProvider.access_token);
508
+ }
509
+ if (!token) {
510
+ const user = await db.getUserById(session.userId);
511
+ if (user?.github_token_encrypted) {
512
+ token = encryption.decrypt(user.github_token_encrypted);
513
+ }
514
+ }
515
+ }
516
+ }
517
+ if (!token) {
518
+ token = process.env.GH_TOKEN || process.env.GITHUB_DEFAULT_TOKEN || null;
519
+ }
520
+ if (!token) {
521
+ res.writeHead(400, { 'Content-Type': 'application/json' });
522
+ res.end(JSON.stringify({ error: 'No GitHub token available' }));
523
+ return;
524
+ }
525
+
526
+ // Fetch repos from GitHub org (up to 100)
527
+ const ghRes = await fetch(`https://api.github.com/orgs/${encodeURIComponent(org)}/repos?per_page=100&sort=name`, {
528
+ headers: {
529
+ 'Authorization': `Bearer ${token}`,
530
+ 'Accept': 'application/vnd.github+json',
531
+ 'X-GitHub-Api-Version': '2022-11-28'
532
+ }
533
+ });
534
+
535
+ if (!ghRes.ok) {
536
+ const text = await ghRes.text();
537
+ res.writeHead(ghRes.status, { 'Content-Type': 'application/json' });
538
+ res.end(JSON.stringify({ error: `GitHub API error (${ghRes.status}): ${text}` }));
539
+ return;
540
+ }
541
+
542
+ const repos = await ghRes.json();
543
+ const repoNames = repos.map(r => r.name).sort();
544
+
545
+ res.writeHead(200, { 'Content-Type': 'application/json' });
546
+ res.end(JSON.stringify(repoNames));
547
+ } catch (error) {
548
+ console.error('GitHub repos fetch error:', error);
549
+ res.writeHead(500, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify({ error: error.message }));
551
+ }
552
+ })();
553
+ return true;
554
+ }
555
+
556
+ // GET /api/github/project-fields?org=<org>&project_number=<num>&token=<optional>
557
+ // Fetch status field ID and options from a GitHub project board
558
+ if (url === '/api/github/project-fields' && method === 'GET') {
559
+ if (!auth.isAuthenticated(req)) {
560
+ res.writeHead(401, { 'Content-Type': 'application/json' });
561
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
562
+ return true;
563
+ }
564
+
565
+ const urlObj = new URL(req.url, `http://${req.headers.host}`);
566
+ const org = urlObj.searchParams.get('org');
567
+ const projectNumber = parseInt(urlObj.searchParams.get('project_number'));
568
+ const projectToken = urlObj.searchParams.get('token') || null;
569
+
570
+ if (!org || !projectNumber) {
571
+ res.writeHead(400, { 'Content-Type': 'application/json' });
572
+ res.end(JSON.stringify({ error: 'org and project_number parameters are required' }));
573
+ return true;
574
+ }
575
+
576
+ (async () => {
577
+ try {
578
+ // Resolve token: unified OAuth table > legacy column > env default
579
+ let token = projectToken;
580
+ if (!token) {
581
+ const sessionId = auth.getSessionFromRequest(req);
582
+ const session = auth.getSession(sessionId);
583
+ if (session?.userId) {
584
+ const oauthProvider = await db.getOAuthProvider(session.userId, 'github');
585
+ if (oauthProvider?.access_token) {
586
+ token = encryption.decrypt(oauthProvider.access_token);
587
+ }
588
+ if (!token) {
589
+ const user = await db.getUserById(session.userId);
590
+ if (user?.github_token_encrypted) {
591
+ token = encryption.decrypt(user.github_token_encrypted);
592
+ }
593
+ }
594
+ }
595
+ }
596
+ if (!token) {
597
+ token = process.env.GH_TOKEN || process.env.GITHUB_DEFAULT_TOKEN || null;
598
+ }
599
+ if (!token) {
600
+ res.writeHead(400, { 'Content-Type': 'application/json' });
601
+ res.end(JSON.stringify({ error: 'No GitHub token available' }));
602
+ return;
603
+ }
604
+
605
+ // GraphQL query to fetch project ID, status field, and its options
606
+ const ghRes = await fetch('https://api.github.com/graphql', {
607
+ method: 'POST',
608
+ headers: {
609
+ 'Authorization': `Bearer ${token}`,
610
+ 'Content-Type': 'application/json'
611
+ },
612
+ body: JSON.stringify({
613
+ query: `query($org: String!, $num: Int!) {
614
+ organization(login: $org) {
615
+ projectV2(number: $num) {
616
+ id
617
+ title
618
+ fields(first: 30) {
619
+ nodes {
620
+ ... on ProjectV2SingleSelectField {
621
+ id
622
+ name
623
+ options {
624
+ id
625
+ name
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+ }
632
+ }`,
633
+ variables: { org, num: projectNumber }
634
+ })
635
+ });
636
+
637
+ if (!ghRes.ok) {
638
+ const text = await ghRes.text();
639
+ res.writeHead(ghRes.status, { 'Content-Type': 'application/json' });
640
+ res.end(JSON.stringify({ error: `GitHub GraphQL error (${ghRes.status}): ${text}` }));
641
+ return;
642
+ }
643
+
644
+ const json = await ghRes.json();
645
+ if (json.errors) {
646
+ res.writeHead(400, { 'Content-Type': 'application/json' });
647
+ res.end(JSON.stringify({ error: json.errors[0].message }));
648
+ return;
649
+ }
650
+
651
+ const project = json.data.organization.projectV2;
652
+ // Find the Status field (single-select field named "Status")
653
+ const statusField = project.fields.nodes.find(f => f.name === 'Status' && f.options);
654
+
655
+ res.writeHead(200, { 'Content-Type': 'application/json' });
656
+ res.end(JSON.stringify({
657
+ project_id: project.id,
658
+ project_title: project.title,
659
+ status_field: statusField ? {
660
+ id: statusField.id,
661
+ name: statusField.name,
662
+ options: statusField.options
663
+ } : null
664
+ }));
665
+ } catch (error) {
666
+ console.error('GitHub project fields fetch error:', error);
667
+ res.writeHead(500, { 'Content-Type': 'application/json' });
668
+ res.end(JSON.stringify({ error: error.message }));
669
+ }
670
+ })();
671
+ return true;
672
+ }
673
+
674
+ // Route not handled
675
+ return false;
676
+ }
677
+
678
+ module.exports = {
679
+ registerRoutes
680
+ };