@oss-autopilot/core 0.41.0 → 0.42.1

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 (63) hide show
  1. package/dist/cli.bundle.cjs +1552 -1318
  2. package/dist/cli.js +593 -69
  3. package/dist/commands/check-integration.d.ts +3 -3
  4. package/dist/commands/check-integration.js +10 -43
  5. package/dist/commands/comments.d.ts +6 -9
  6. package/dist/commands/comments.js +102 -252
  7. package/dist/commands/config.d.ts +8 -2
  8. package/dist/commands/config.js +6 -28
  9. package/dist/commands/daily.d.ts +28 -4
  10. package/dist/commands/daily.js +33 -45
  11. package/dist/commands/dashboard-data.js +7 -6
  12. package/dist/commands/dashboard-server.d.ts +14 -0
  13. package/dist/commands/dashboard-server.js +362 -0
  14. package/dist/commands/dashboard.d.ts +5 -0
  15. package/dist/commands/dashboard.js +51 -1
  16. package/dist/commands/dismiss.d.ts +13 -5
  17. package/dist/commands/dismiss.js +4 -24
  18. package/dist/commands/index.d.ts +33 -0
  19. package/dist/commands/index.js +22 -0
  20. package/dist/commands/init.d.ts +5 -4
  21. package/dist/commands/init.js +4 -14
  22. package/dist/commands/local-repos.d.ts +4 -5
  23. package/dist/commands/local-repos.js +6 -33
  24. package/dist/commands/parse-list.d.ts +3 -4
  25. package/dist/commands/parse-list.js +8 -39
  26. package/dist/commands/read.d.ts +11 -5
  27. package/dist/commands/read.js +4 -18
  28. package/dist/commands/search.d.ts +3 -3
  29. package/dist/commands/search.js +39 -65
  30. package/dist/commands/setup.d.ts +34 -5
  31. package/dist/commands/setup.js +75 -166
  32. package/dist/commands/shelve.d.ts +13 -5
  33. package/dist/commands/shelve.js +4 -24
  34. package/dist/commands/snooze.d.ts +15 -9
  35. package/dist/commands/snooze.js +16 -59
  36. package/dist/commands/startup.d.ts +11 -6
  37. package/dist/commands/startup.js +44 -82
  38. package/dist/commands/status.d.ts +3 -3
  39. package/dist/commands/status.js +10 -29
  40. package/dist/commands/track.d.ts +10 -9
  41. package/dist/commands/track.js +17 -39
  42. package/dist/commands/validation.d.ts +2 -2
  43. package/dist/commands/validation.js +7 -15
  44. package/dist/commands/vet.d.ts +3 -3
  45. package/dist/commands/vet.js +16 -26
  46. package/dist/core/errors.d.ts +9 -0
  47. package/dist/core/errors.js +17 -0
  48. package/dist/core/github-stats.d.ts +14 -21
  49. package/dist/core/github-stats.js +84 -138
  50. package/dist/core/http-cache.d.ts +6 -0
  51. package/dist/core/http-cache.js +16 -4
  52. package/dist/core/index.d.ts +2 -1
  53. package/dist/core/index.js +2 -1
  54. package/dist/core/issue-conversation.js +4 -4
  55. package/dist/core/issue-discovery.js +14 -14
  56. package/dist/core/issue-vetting.js +17 -17
  57. package/dist/core/pr-monitor.d.ts +6 -20
  58. package/dist/core/pr-monitor.js +11 -52
  59. package/dist/core/state.js +4 -5
  60. package/dist/core/utils.d.ts +11 -0
  61. package/dist/core/utils.js +21 -0
  62. package/dist/formatters/json.d.ts +58 -0
  63. package/package.json +5 -1
@@ -6,31 +6,12 @@
6
6
  * Domain logic lives in src/core/daily-logic.ts; this file is a thin
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
- import { getStateManager, PRMonitor, IssueConversationMonitor, getGitHubToken, CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, } from '../core/index.js';
10
- import { outputJson, outputJsonError, deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
9
+ import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
10
+ import { errorMessage } from '../core/errors.js';
11
+ import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
11
12
  // Re-export domain functions so existing consumers (tests, dashboard, startup)
12
13
  // can continue importing from './daily.js' without changes.
13
14
  export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
14
- export async function runDaily(options) {
15
- // Token is guaranteed by the preAction hook in cli.ts for non-LOCAL_ONLY_COMMANDS.
16
- const token = getGitHubToken();
17
- try {
18
- await runDailyInner(token, options);
19
- }
20
- catch (error) {
21
- const msg = error instanceof Error ? error.message : String(error);
22
- if (options.json) {
23
- outputJsonError(`Daily check failed: ${msg}`);
24
- }
25
- else {
26
- console.error(`[FATAL] Daily check failed: ${msg}`);
27
- if (error instanceof Error && error.stack) {
28
- console.error(error.stack);
29
- }
30
- }
31
- process.exit(1);
32
- }
33
- }
34
15
  // ---------------------------------------------------------------------------
35
16
  // Phase functions
36
17
  // ---------------------------------------------------------------------------
@@ -53,15 +34,15 @@ async function fetchPRData(prMonitor, token) {
53
34
  prMonitor.fetchUserMergedPRCounts(),
54
35
  prMonitor.fetchUserClosedPRCounts(),
55
36
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
56
- console.error(`Warning: Failed to fetch recently closed PRs: ${err instanceof Error ? err.message : err}`);
37
+ console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
57
38
  return [];
58
39
  }),
59
40
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
60
- console.error(`Warning: Failed to fetch recently merged PRs: ${err instanceof Error ? err.message : err}`);
41
+ console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
61
42
  return [];
62
43
  }),
63
44
  issueMonitor.fetchCommentedIssues().catch((error) => {
64
- const msg = error instanceof Error ? error.message : String(error);
45
+ const msg = errorMessage(error);
65
46
  if (msg.includes('No GitHub username configured')) {
66
47
  console.error(`[DAILY] Issue conversation tracking requires setup: ${msg}`);
67
48
  }
@@ -123,7 +104,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
123
104
  }
124
105
  catch (error) {
125
106
  mergedCountFailures++;
126
- console.error(`[DAILY] Failed to update merged count for ${repo}:`, error instanceof Error ? error.message : error);
107
+ console.error(`[DAILY] Failed to update merged count for ${repo}:`, errorMessage(error));
127
108
  }
128
109
  }
129
110
  if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
@@ -143,7 +124,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
143
124
  }
144
125
  catch (error) {
145
126
  closedCountFailures++;
146
- console.error(`[DAILY] Failed to update closed count for ${repo}:`, error instanceof Error ? error.message : error);
127
+ console.error(`[DAILY] Failed to update closed count for ${repo}:`, errorMessage(error));
147
128
  }
148
129
  }
149
130
  if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
@@ -162,7 +143,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
162
143
  }
163
144
  catch (error) {
164
145
  signalUpdateFailures++;
165
- console.error(`[DAILY] Failed to update signals for ${repo}:`, error instanceof Error ? error.message : error);
146
+ console.error(`[DAILY] Failed to update signals for ${repo}:`, errorMessage(error));
166
147
  }
167
148
  }
168
149
  if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
@@ -175,7 +156,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
175
156
  starCounts = await prMonitor.fetchRepoStarCounts(allRepos);
176
157
  }
177
158
  catch (error) {
178
- console.error('[DAILY] Failed to fetch repo star counts:', error instanceof Error ? error.message : error);
159
+ console.error('[DAILY] Failed to fetch repo star counts:', errorMessage(error));
179
160
  console.error('[DAILY] Dashboard minStars filter will use cached star counts (or be skipped for repos without cached data).');
180
161
  starCounts = new Map();
181
162
  }
@@ -186,7 +167,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
186
167
  }
187
168
  catch (error) {
188
169
  starUpdateFailures++;
189
- console.error(`[DAILY] Failed to update star count for ${repo}:`, error instanceof Error ? error.message : error);
170
+ console.error(`[DAILY] Failed to update star count for ${repo}:`, errorMessage(error));
190
171
  }
191
172
  }
192
173
  if (starUpdateFailures === starCounts.size && starCounts.size > 0) {
@@ -200,7 +181,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
200
181
  }
201
182
  catch (error) {
202
183
  trustSyncFailures++;
203
- console.error(`[DAILY] Failed to sync trusted project ${repo}:`, error instanceof Error ? error.message : error);
184
+ console.error(`[DAILY] Failed to sync trusted project ${repo}:`, errorMessage(error));
204
185
  }
205
186
  }
206
187
  if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
@@ -219,13 +200,13 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
219
200
  stateManager.setMonthlyMergedCounts(monthlyCounts);
220
201
  }
221
202
  catch (error) {
222
- console.error('[DAILY] Failed to store monthly merged counts:', error instanceof Error ? error.message : error);
203
+ console.error('[DAILY] Failed to store monthly merged counts:', errorMessage(error));
223
204
  }
224
205
  try {
225
206
  stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
226
207
  }
227
208
  catch (error) {
228
- console.error('[DAILY] Failed to store monthly closed counts:', error instanceof Error ? error.message : error);
209
+ console.error('[DAILY] Failed to store monthly closed counts:', errorMessage(error));
229
210
  }
230
211
  try {
231
212
  // Build combined monthly opened counts from merged + closed + currently-open PRs
@@ -243,7 +224,7 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
243
224
  stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
244
225
  }
245
226
  catch (error) {
246
- console.error('[DAILY] Failed to compute/store monthly opened counts:', error instanceof Error ? error.message : error);
227
+ console.error('[DAILY] Failed to compute/store monthly opened counts:', errorMessage(error));
247
228
  }
248
229
  }
249
230
  /**
@@ -266,7 +247,7 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
266
247
  }
267
248
  }
268
249
  catch (error) {
269
- console.error('[DAILY] Failed to expire/persist snoozes:', error instanceof Error ? error.message : error);
250
+ console.error('[DAILY] Failed to expire/persist snoozes:', errorMessage(error));
270
251
  }
271
252
  // Partition PRs into active vs shelved, auto-unshelving when maintainers engage
272
253
  const shelvedPRs = [];
@@ -406,7 +387,7 @@ export async function executeDailyCheck(token) {
406
387
  }
407
388
  /**
408
389
  * Internal daily check returning full (non-deduplicated) result.
409
- * Used by runDailyInner for text-mode output where full PR objects are needed.
390
+ * Used by runDaily for text-mode output where full PR objects are needed.
410
391
  */
411
392
  async function executeDailyCheckInternal(token) {
412
393
  const prMonitor = new PRMonitor(token);
@@ -421,13 +402,20 @@ async function executeDailyCheckInternal(token) {
421
402
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
422
403
  return generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures);
423
404
  }
424
- async function runDailyInner(token, options) {
425
- if (options.json) {
426
- const result = await executeDailyCheck(token);
427
- outputJson(result);
428
- }
429
- else {
430
- const result = await executeDailyCheckInternal(token);
431
- printDigest(result.digest, result.capacity, result.commentedIssues);
432
- }
405
+ /**
406
+ * Run the daily check and return deduplicated DailyOutput.
407
+ * Errors propagate to the caller.
408
+ */
409
+ export async function runDaily() {
410
+ // Token is guaranteed by the preAction hook in cli.ts for non-LOCAL_ONLY_COMMANDS.
411
+ const token = requireGitHubToken();
412
+ return executeDailyCheck(token);
413
+ }
414
+ /**
415
+ * Run the daily check and return the full (non-deduplicated) result.
416
+ * Used by CLI text mode where printDigest() needs full PR objects.
417
+ */
418
+ export async function runDailyForDisplay() {
419
+ const token = requireGitHubToken();
420
+ return executeDailyCheckInternal(token);
433
421
  }
@@ -4,6 +4,7 @@
4
4
  * Separates data concerns from template generation and command orchestration.
5
5
  */
6
6
  import { getStateManager, PRMonitor, IssueConversationMonitor } from '../core/index.js';
7
+ import { errorMessage } from '../core/errors.js';
7
8
  import { toShelvedPRRef } from './daily.js';
8
9
  /**
9
10
  * Fetch fresh dashboard data from GitHub.
@@ -17,17 +18,17 @@ export async function fetchDashboardData(token) {
17
18
  const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues] = await Promise.all([
18
19
  prMonitor.fetchUserOpenPRs(),
19
20
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
20
- console.error(`Warning: Failed to fetch recently closed PRs: ${err instanceof Error ? err.message : err}`);
21
+ console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
21
22
  return [];
22
23
  }),
23
24
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
24
- console.error(`Warning: Failed to fetch recently merged PRs: ${err instanceof Error ? err.message : err}`);
25
+ console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
25
26
  return [];
26
27
  }),
27
28
  prMonitor.fetchUserMergedPRCounts(),
28
29
  prMonitor.fetchUserClosedPRCounts(),
29
30
  issueMonitor.fetchCommentedIssues().catch((error) => {
30
- const msg = error instanceof Error ? error.message : String(error);
31
+ const msg = errorMessage(error);
31
32
  if (msg.includes('No GitHub username configured')) {
32
33
  console.error(`[DASHBOARD] Issue conversation tracking requires setup: ${msg}`);
33
34
  }
@@ -54,13 +55,13 @@ export async function fetchDashboardData(token) {
54
55
  stateManager.setMonthlyMergedCounts(monthlyCounts);
55
56
  }
56
57
  catch (error) {
57
- console.error('[DASHBOARD] Failed to store monthly merged counts:', error instanceof Error ? error.message : error);
58
+ console.error('[DASHBOARD] Failed to store monthly merged counts:', errorMessage(error));
58
59
  }
59
60
  try {
60
61
  stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
61
62
  }
62
63
  catch (error) {
63
- console.error('[DASHBOARD] Failed to store monthly closed counts:', error instanceof Error ? error.message : error);
64
+ console.error('[DASHBOARD] Failed to store monthly closed counts:', errorMessage(error));
64
65
  }
65
66
  try {
66
67
  const combinedOpenedCounts = { ...openedFromMerged };
@@ -76,7 +77,7 @@ export async function fetchDashboardData(token) {
76
77
  stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
77
78
  }
78
79
  catch (error) {
79
- console.error('[DASHBOARD] Failed to store monthly opened counts:', error instanceof Error ? error.message : error);
80
+ console.error('[DASHBOARD] Failed to store monthly opened counts:', errorMessage(error));
80
81
  }
81
82
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
82
83
  // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Dashboard HTTP server.
3
+ * Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
4
+ * for live data fetching and state mutations (shelve, snooze, etc.).
5
+ *
6
+ * Uses Node's built-in http module — no Express/Fastify.
7
+ */
8
+ export interface DashboardServerOptions {
9
+ port: number;
10
+ assetsDir: string;
11
+ token: string | null;
12
+ open: boolean;
13
+ }
14
+ export declare function startDashboardServer(options: DashboardServerOptions): Promise<void>;
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Dashboard HTTP server.
3
+ * Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
4
+ * for live data fetching and state mutations (shelve, snooze, etc.).
5
+ *
6
+ * Uses Node's built-in http module — no Express/Fastify.
7
+ */
8
+ import * as http from 'http';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { getStateManager, getGitHubToken } from '../core/index.js';
12
+ import { errorMessage } from '../core/errors.js';
13
+ import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
14
+ import { buildDashboardStats } from './dashboard-templates.js';
15
+ // ── Constants ────────────────────────────────────────────────────────────────
16
+ const VALID_ACTIONS = new Set(['shelve', 'unshelve', 'snooze', 'unsnooze']);
17
+ const MAX_BODY_BYTES = 10_240;
18
+ const MIME_TYPES = {
19
+ '.html': 'text/html',
20
+ '.js': 'application/javascript',
21
+ '.css': 'text/css',
22
+ '.svg': 'image/svg+xml',
23
+ '.json': 'application/json',
24
+ '.png': 'image/png',
25
+ '.ico': 'image/x-icon',
26
+ };
27
+ // ── Helpers ────────────────────────────────────────────────────────────────────
28
+ /**
29
+ * Build the JSON payload that the SPA expects from GET /api/data.
30
+ * Same shape as the existing `dashboard --json` output.
31
+ */
32
+ function buildDashboardJson(digest, state, commentedIssues) {
33
+ const prsByRepo = computePRsByRepo(digest, state);
34
+ const topRepos = computeTopRepos(prsByRepo);
35
+ const { monthlyMerged } = getMonthlyData(state);
36
+ const stats = buildDashboardStats(digest, state);
37
+ const issueResponses = commentedIssues.filter((i) => i.status === 'new_response');
38
+ return {
39
+ stats,
40
+ prsByRepo,
41
+ topRepos: topRepos.map(([repo, data]) => ({ repo, ...data })),
42
+ monthlyMerged,
43
+ activePRs: digest.openPRs || [],
44
+ shelvedPRUrls: state.config.shelvedPRUrls || [],
45
+ commentedIssues,
46
+ issueResponses,
47
+ };
48
+ }
49
+ /**
50
+ * Read the full request body as a UTF-8 string, with a size limit.
51
+ */
52
+ function readBody(req, maxBytes = MAX_BODY_BYTES) {
53
+ return new Promise((resolve, reject) => {
54
+ const chunks = [];
55
+ let totalLength = 0;
56
+ let aborted = false;
57
+ req.on('data', (chunk) => {
58
+ if (aborted)
59
+ return;
60
+ totalLength += chunk.length;
61
+ if (totalLength > maxBytes) {
62
+ aborted = true;
63
+ req.destroy();
64
+ reject(new Error('Body too large'));
65
+ return;
66
+ }
67
+ chunks.push(chunk);
68
+ });
69
+ req.on('end', () => {
70
+ if (!aborted)
71
+ resolve(Buffer.concat(chunks).toString('utf-8'));
72
+ });
73
+ req.on('error', (err) => {
74
+ if (!aborted)
75
+ reject(err);
76
+ });
77
+ });
78
+ }
79
+ /**
80
+ * Send a JSON response.
81
+ */
82
+ function sendJson(res, statusCode, data) {
83
+ const body = JSON.stringify(data);
84
+ res.writeHead(statusCode, {
85
+ 'Content-Type': 'application/json',
86
+ 'Content-Length': Buffer.byteLength(body),
87
+ });
88
+ res.end(body);
89
+ }
90
+ /**
91
+ * Send an error JSON response.
92
+ */
93
+ function sendError(res, statusCode, message) {
94
+ sendJson(res, statusCode, { error: message });
95
+ }
96
+ // ── Server ─────────────────────────────────────────────────────────────────────
97
+ export async function startDashboardServer(options) {
98
+ const { port: requestedPort, assetsDir, token, open } = options;
99
+ const stateManager = getStateManager();
100
+ const resolvedAssetsDir = path.resolve(assetsDir);
101
+ // ── Cached data ──────────────────────────────────────────────────────────
102
+ let cachedDigest;
103
+ let cachedCommentedIssues = [];
104
+ // Fetch initial data
105
+ if (token) {
106
+ try {
107
+ console.error('Fetching dashboard data from GitHub...');
108
+ const result = await fetchDashboardData(token);
109
+ cachedDigest = result.digest;
110
+ cachedCommentedIssues = result.commentedIssues;
111
+ }
112
+ catch (error) {
113
+ console.error('Failed to fetch data from GitHub:', error);
114
+ console.error('Falling back to cached data...');
115
+ cachedDigest = stateManager.getState().lastDigest;
116
+ }
117
+ }
118
+ else {
119
+ cachedDigest = stateManager.getState().lastDigest;
120
+ }
121
+ if (!cachedDigest) {
122
+ console.error('No dashboard data available. Run the daily check first:');
123
+ console.error(' GITHUB_TOKEN=$(gh auth token) npm start -- daily');
124
+ process.exit(1);
125
+ }
126
+ // ── Build cached JSON response ───────────────────────────────────────────
127
+ let cachedJsonData;
128
+ try {
129
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
130
+ }
131
+ catch (error) {
132
+ console.error('Failed to build dashboard data from cached digest:', error);
133
+ console.error('Your state data may be corrupted. Try running: daily --json');
134
+ process.exit(1);
135
+ }
136
+ // ── Request handler ──────────────────────────────────────────────────────
137
+ const server = http.createServer(async (req, res) => {
138
+ const method = req.method || 'GET';
139
+ const url = req.url || '/';
140
+ try {
141
+ // ── API routes ─────────────────────────────────────────────────────
142
+ if (url === '/api/data' && method === 'GET') {
143
+ sendJson(res, 200, cachedJsonData);
144
+ return;
145
+ }
146
+ if (url === '/api/action' && method === 'POST') {
147
+ await handleAction(req, res);
148
+ return;
149
+ }
150
+ if (url === '/api/refresh' && method === 'POST') {
151
+ await handleRefresh(req, res);
152
+ return;
153
+ }
154
+ // ── Static file serving ────────────────────────────────────────────
155
+ if (method === 'GET') {
156
+ serveStaticFile(url, res);
157
+ return;
158
+ }
159
+ sendError(res, 405, 'Method not allowed');
160
+ }
161
+ catch (error) {
162
+ console.error('Unhandled request error:', method, url, error);
163
+ if (!res.headersSent) {
164
+ sendError(res, 500, 'Internal server error');
165
+ }
166
+ }
167
+ });
168
+ // ── POST /api/action handler ─────────────────────────────────────────────
169
+ async function handleAction(req, res) {
170
+ let body;
171
+ try {
172
+ const raw = await readBody(req);
173
+ body = JSON.parse(raw);
174
+ }
175
+ catch (e) {
176
+ const isBodyTooLarge = e instanceof Error && e.message === 'Body too large';
177
+ sendError(res, isBodyTooLarge ? 413 : 400, isBodyTooLarge ? 'Request body too large' : 'Invalid JSON body');
178
+ return;
179
+ }
180
+ if (!body.action || !VALID_ACTIONS.has(body.action)) {
181
+ sendError(res, 400, `Invalid action. Must be one of: ${[...VALID_ACTIONS].join(', ')}`);
182
+ return;
183
+ }
184
+ if (!body.url || typeof body.url !== 'string') {
185
+ sendError(res, 400, 'Missing or invalid "url" field');
186
+ return;
187
+ }
188
+ try {
189
+ switch (body.action) {
190
+ case 'shelve':
191
+ stateManager.shelvePR(body.url);
192
+ break;
193
+ case 'unshelve':
194
+ stateManager.unshelvePR(body.url);
195
+ break;
196
+ case 'snooze':
197
+ stateManager.snoozePR(body.url, body.reason || 'Snoozed via dashboard', body.days || 7);
198
+ break;
199
+ case 'unsnooze':
200
+ stateManager.unsnoozePR(body.url);
201
+ break;
202
+ }
203
+ stateManager.save();
204
+ }
205
+ catch (error) {
206
+ console.error('Action failed:', body.action, body.url, error);
207
+ sendError(res, 500, `Action failed: ${errorMessage(error)}`);
208
+ return;
209
+ }
210
+ // Rebuild dashboard data from cached digest + updated state
211
+ if (cachedDigest) {
212
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
213
+ }
214
+ sendJson(res, 200, cachedJsonData);
215
+ }
216
+ // ── POST /api/refresh handler ────────────────────────────────────────────
217
+ async function handleRefresh(_req, res) {
218
+ const currentToken = token || getGitHubToken();
219
+ if (!currentToken) {
220
+ sendError(res, 401, 'No GitHub token available. Cannot refresh data.');
221
+ return;
222
+ }
223
+ try {
224
+ console.error('Refreshing dashboard data from GitHub...');
225
+ const result = await fetchDashboardData(currentToken);
226
+ cachedDigest = result.digest;
227
+ cachedCommentedIssues = result.commentedIssues;
228
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
229
+ sendJson(res, 200, cachedJsonData);
230
+ }
231
+ catch (error) {
232
+ console.error('Dashboard refresh failed:', error);
233
+ sendError(res, 500, `Refresh failed: ${errorMessage(error)}`);
234
+ }
235
+ }
236
+ // ── Static file serving ──────────────────────────────────────────────────
237
+ function serveStaticFile(requestUrl, res) {
238
+ // Decode URL, handling malformed percent-encoding
239
+ let urlPath;
240
+ try {
241
+ urlPath = decodeURIComponent(requestUrl.split('?')[0]);
242
+ }
243
+ catch (err) {
244
+ console.error('Malformed URL received:', requestUrl, err);
245
+ sendError(res, 400, 'Malformed URL');
246
+ return;
247
+ }
248
+ // Security: reject paths with parent directory references
249
+ if (urlPath.includes('..')) {
250
+ sendError(res, 403, 'Forbidden');
251
+ return;
252
+ }
253
+ // Resolve the file path from sanitized URL
254
+ const relativePath = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
255
+ let filePath = path.join(resolvedAssetsDir, relativePath);
256
+ // Belt-and-suspenders: ensure resolved path is within assets directory
257
+ if (!filePath.startsWith(resolvedAssetsDir + path.sep) && filePath !== resolvedAssetsDir) {
258
+ sendError(res, 403, 'Forbidden');
259
+ return;
260
+ }
261
+ // If file doesn't exist or is a directory, serve index.html for SPA routing
262
+ try {
263
+ const stat = fs.statSync(filePath);
264
+ if (stat.isDirectory()) {
265
+ filePath = path.join(resolvedAssetsDir, 'index.html');
266
+ }
267
+ }
268
+ catch (err) {
269
+ const nodeErr = err;
270
+ if (nodeErr.code === 'ENOENT') {
271
+ filePath = path.join(resolvedAssetsDir, 'index.html');
272
+ }
273
+ else {
274
+ console.error('Failed to stat file:', filePath, err);
275
+ sendError(res, 500, 'Internal server error');
276
+ return;
277
+ }
278
+ }
279
+ const ext = path.extname(filePath).toLowerCase();
280
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
281
+ try {
282
+ const content = fs.readFileSync(filePath);
283
+ res.writeHead(200, {
284
+ 'Content-Type': contentType,
285
+ 'Content-Length': content.length,
286
+ });
287
+ res.end(content);
288
+ }
289
+ catch (error) {
290
+ const nodeErr = error;
291
+ if (nodeErr.code === 'ENOENT') {
292
+ sendError(res, 404, 'Not found');
293
+ }
294
+ else {
295
+ console.error('Failed to serve static file:', filePath, error);
296
+ sendError(res, 500, 'Failed to read file');
297
+ }
298
+ }
299
+ }
300
+ // ── Start server with port retry ─────────────────────────────────────────
301
+ const MAX_PORT_ATTEMPTS = 10;
302
+ let actualPort = requestedPort;
303
+ for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
304
+ try {
305
+ await new Promise((resolve, reject) => {
306
+ server.once('error', reject);
307
+ server.listen(actualPort, '127.0.0.1', () => resolve());
308
+ });
309
+ // Success — break out of retry loop
310
+ break;
311
+ }
312
+ catch (err) {
313
+ const nodeErr = err;
314
+ if (nodeErr.code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS - 1) {
315
+ console.error(`Port ${actualPort} is in use, trying ${actualPort + 1}...`);
316
+ actualPort++;
317
+ continue;
318
+ }
319
+ console.error(`Failed to start server: ${nodeErr.message}`);
320
+ process.exit(1);
321
+ }
322
+ }
323
+ const serverUrl = `http://localhost:${actualPort}`;
324
+ console.error(`Dashboard server running at ${serverUrl}`);
325
+ // ── Open browser ─────────────────────────────────────────────────────────
326
+ if (open) {
327
+ const { execFile } = await import('child_process');
328
+ let openCmd;
329
+ let args;
330
+ switch (process.platform) {
331
+ case 'darwin':
332
+ openCmd = 'open';
333
+ args = [serverUrl];
334
+ break;
335
+ case 'win32':
336
+ openCmd = 'cmd';
337
+ args = ['/c', 'start', '', serverUrl];
338
+ break;
339
+ default:
340
+ openCmd = 'xdg-open';
341
+ args = [serverUrl];
342
+ break;
343
+ }
344
+ execFile(openCmd, args, (error) => {
345
+ if (error) {
346
+ console.error('Failed to open browser:', error.message);
347
+ console.error(`Open manually: ${serverUrl}`);
348
+ }
349
+ });
350
+ }
351
+ // ── Clean shutdown ───────────────────────────────────────────────────────
352
+ const shutdown = () => {
353
+ console.error('\nShutting down dashboard server...');
354
+ server.close(() => {
355
+ process.exit(0);
356
+ });
357
+ // Force exit after 3 seconds if server doesn't close cleanly
358
+ setTimeout(() => process.exit(0), 3000).unref();
359
+ };
360
+ process.on('SIGINT', shutdown);
361
+ process.on('SIGTERM', shutdown);
362
+ }
@@ -15,4 +15,9 @@ export declare function runDashboard(options: DashboardOptions): Promise<void>;
15
15
  * Returns the path to the generated dashboard HTML file.
16
16
  */
17
17
  export declare function writeDashboardFromState(): string;
18
+ interface ServeOptions {
19
+ port: number;
20
+ open: boolean;
21
+ }
22
+ export declare function serveDashboard(options: ServeOptions): Promise<void>;
18
23
  export {};