@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.
- package/dist/cli.bundle.cjs +1552 -1318
- package/dist/cli.js +593 -69
- package/dist/commands/check-integration.d.ts +3 -3
- package/dist/commands/check-integration.js +10 -43
- package/dist/commands/comments.d.ts +6 -9
- package/dist/commands/comments.js +102 -252
- package/dist/commands/config.d.ts +8 -2
- package/dist/commands/config.js +6 -28
- package/dist/commands/daily.d.ts +28 -4
- package/dist/commands/daily.js +33 -45
- package/dist/commands/dashboard-data.js +7 -6
- package/dist/commands/dashboard-server.d.ts +14 -0
- package/dist/commands/dashboard-server.js +362 -0
- package/dist/commands/dashboard.d.ts +5 -0
- package/dist/commands/dashboard.js +51 -1
- package/dist/commands/dismiss.d.ts +13 -5
- package/dist/commands/dismiss.js +4 -24
- package/dist/commands/index.d.ts +33 -0
- package/dist/commands/index.js +22 -0
- package/dist/commands/init.d.ts +5 -4
- package/dist/commands/init.js +4 -14
- package/dist/commands/local-repos.d.ts +4 -5
- package/dist/commands/local-repos.js +6 -33
- package/dist/commands/parse-list.d.ts +3 -4
- package/dist/commands/parse-list.js +8 -39
- package/dist/commands/read.d.ts +11 -5
- package/dist/commands/read.js +4 -18
- package/dist/commands/search.d.ts +3 -3
- package/dist/commands/search.js +39 -65
- package/dist/commands/setup.d.ts +34 -5
- package/dist/commands/setup.js +75 -166
- package/dist/commands/shelve.d.ts +13 -5
- package/dist/commands/shelve.js +4 -24
- package/dist/commands/snooze.d.ts +15 -9
- package/dist/commands/snooze.js +16 -59
- package/dist/commands/startup.d.ts +11 -6
- package/dist/commands/startup.js +44 -82
- package/dist/commands/status.d.ts +3 -3
- package/dist/commands/status.js +10 -29
- package/dist/commands/track.d.ts +10 -9
- package/dist/commands/track.js +17 -39
- package/dist/commands/validation.d.ts +2 -2
- package/dist/commands/validation.js +7 -15
- package/dist/commands/vet.d.ts +3 -3
- package/dist/commands/vet.js +16 -26
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +17 -0
- package/dist/core/github-stats.d.ts +14 -21
- package/dist/core/github-stats.js +84 -138
- package/dist/core/http-cache.d.ts +6 -0
- package/dist/core/http-cache.js +16 -4
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +2 -1
- package/dist/core/issue-conversation.js +4 -4
- package/dist/core/issue-discovery.js +14 -14
- package/dist/core/issue-vetting.js +17 -17
- package/dist/core/pr-monitor.d.ts +6 -20
- package/dist/core/pr-monitor.js +11 -52
- package/dist/core/state.js +4 -5
- package/dist/core/utils.d.ts +11 -0
- package/dist/core/utils.js +21 -0
- package/dist/formatters/json.d.ts +58 -0
- package/package.json +5 -1
package/dist/commands/daily.js
CHANGED
|
@@ -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,
|
|
10
|
-
import {
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 {};
|