@oss-autopilot/core 0.44.2 → 0.44.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/cli-registry.js +61 -0
- package/dist/cli.bundle.cjs +101 -127
- package/dist/cli.bundle.cjs.map +4 -4
- package/dist/commands/daily.d.ts +6 -1
- package/dist/commands/daily.js +29 -64
- package/dist/commands/dashboard-data.d.ts +22 -1
- package/dist/commands/dashboard-data.js +85 -62
- package/dist/commands/dashboard-lifecycle.js +39 -2
- package/dist/commands/dashboard-scripts.d.ts +1 -1
- package/dist/commands/dashboard-scripts.js +2 -1
- package/dist/commands/dashboard-server.d.ts +2 -1
- package/dist/commands/dashboard-server.js +120 -81
- package/dist/commands/dashboard-templates.js +15 -69
- package/dist/commands/override.d.ts +21 -0
- package/dist/commands/override.js +35 -0
- package/dist/core/checklist-analysis.js +3 -1
- package/dist/core/daily-logic.d.ts +13 -10
- package/dist/core/daily-logic.js +79 -166
- package/dist/core/display-utils.d.ts +4 -0
- package/dist/core/display-utils.js +53 -54
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.js +26 -0
- package/dist/core/github-stats.d.ts +3 -3
- package/dist/core/github-stats.js +15 -7
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +2 -2
- package/dist/core/issue-conversation.js +2 -2
- package/dist/core/issue-discovery.d.ts +0 -5
- package/dist/core/issue-discovery.js +4 -11
- package/dist/core/issue-vetting.d.ts +0 -2
- package/dist/core/issue-vetting.js +31 -45
- package/dist/core/pr-monitor.d.ts +26 -3
- package/dist/core/pr-monitor.js +106 -93
- package/dist/core/state.d.ts +22 -1
- package/dist/core/state.js +50 -1
- package/dist/core/test-utils.js +6 -16
- package/dist/core/types.d.ts +51 -38
- package/dist/core/types.js +8 -0
- package/dist/core/utils.d.ts +2 -0
- package/dist/core/utils.js +5 -1
- package/dist/formatters/json.d.ts +1 -13
- package/dist/formatters/json.js +1 -13
- package/package.json +2 -2
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dashboard HTTP server.
|
|
3
3
|
* Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
|
|
4
|
-
* for live data fetching and state mutations (shelve,
|
|
4
|
+
* for live data fetching and state mutations (shelve, unshelve, override, etc.).
|
|
5
5
|
*
|
|
6
6
|
* Uses Node's built-in http module — no Express/Fastify.
|
|
7
7
|
*/
|
|
8
8
|
import * as http from 'http';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { getStateManager, getGitHubToken, getDataDir } from '../core/index.js';
|
|
11
|
+
import { getStateManager, getGitHubToken, getDataDir, getCLIVersion } from '../core/index.js';
|
|
12
12
|
import { errorMessage, ValidationError } from '../core/errors.js';
|
|
13
|
-
import {
|
|
13
|
+
import { warn } from '../core/logger.js';
|
|
14
|
+
import { validateUrl, validateGitHubUrl, PR_URL_PATTERN } from './validation.js';
|
|
14
15
|
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, } from './dashboard-data.js';
|
|
15
16
|
import { openInBrowser } from './startup.js';
|
|
16
17
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
17
|
-
const VALID_ACTIONS = new Set([
|
|
18
|
-
|
|
19
|
-
'unshelve',
|
|
20
|
-
'snooze',
|
|
21
|
-
'unsnooze',
|
|
22
|
-
'dismiss',
|
|
23
|
-
'undismiss',
|
|
24
|
-
]);
|
|
18
|
+
const VALID_ACTIONS = new Set(['shelve', 'unshelve', 'override_status']);
|
|
19
|
+
const MODULE = 'dashboard-server';
|
|
25
20
|
const MAX_BODY_BYTES = 10_240;
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
26
22
|
const MIME_TYPES = {
|
|
27
23
|
'.html': 'text/html',
|
|
28
24
|
'.js': 'application/javascript',
|
|
@@ -32,6 +28,40 @@ const MIME_TYPES = {
|
|
|
32
28
|
'.png': 'image/png',
|
|
33
29
|
'.ico': 'image/x-icon',
|
|
34
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Apply status overrides from state to the PR list.
|
|
33
|
+
* Overrides are auto-cleared if the PR has new activity since the override was set.
|
|
34
|
+
*/
|
|
35
|
+
function applyStatusOverrides(prs, state) {
|
|
36
|
+
const overrides = state.config.statusOverrides;
|
|
37
|
+
if (!overrides || Object.keys(overrides).length === 0)
|
|
38
|
+
return prs;
|
|
39
|
+
const stateManager = getStateManager();
|
|
40
|
+
// Snapshot keys before iteration — clearStatusOverride mutates the same object
|
|
41
|
+
const overrideUrls = new Set(Object.keys(overrides));
|
|
42
|
+
let didAutoClear = false;
|
|
43
|
+
const result = prs.map((pr) => {
|
|
44
|
+
const override = stateManager.getStatusOverride(pr.url, pr.updatedAt);
|
|
45
|
+
if (!override) {
|
|
46
|
+
if (overrideUrls.has(pr.url))
|
|
47
|
+
didAutoClear = true;
|
|
48
|
+
return pr;
|
|
49
|
+
}
|
|
50
|
+
if (override.status === pr.status)
|
|
51
|
+
return pr;
|
|
52
|
+
return { ...pr, status: override.status };
|
|
53
|
+
});
|
|
54
|
+
// Persist any auto-cleared overrides so they don't resurrect on restart
|
|
55
|
+
if (didAutoClear) {
|
|
56
|
+
try {
|
|
57
|
+
stateManager.save();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
warn(MODULE, `Failed to persist auto-cleared overrides — they may reappear on restart: ${errorMessage(err)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
35
65
|
// ── PID File Management ──────────────────────────────────────────────────────
|
|
36
66
|
export function getDashboardPidPath() {
|
|
37
67
|
return path.join(getDataDir(), 'dashboard-server.pid');
|
|
@@ -46,9 +76,11 @@ export function readDashboardServerInfo() {
|
|
|
46
76
|
if (typeof parsed !== 'object' ||
|
|
47
77
|
parsed === null ||
|
|
48
78
|
typeof parsed.pid !== 'number' ||
|
|
79
|
+
!Number.isInteger(parsed.pid) ||
|
|
80
|
+
parsed.pid <= 0 ||
|
|
49
81
|
typeof parsed.port !== 'number' ||
|
|
50
82
|
typeof parsed.startedAt !== 'string') {
|
|
51
|
-
|
|
83
|
+
warn(MODULE, 'PID file has invalid structure, ignoring');
|
|
52
84
|
return null;
|
|
53
85
|
}
|
|
54
86
|
return parsed;
|
|
@@ -56,7 +88,7 @@ export function readDashboardServerInfo() {
|
|
|
56
88
|
catch (err) {
|
|
57
89
|
const code = err.code;
|
|
58
90
|
if (code !== 'ENOENT') {
|
|
59
|
-
|
|
91
|
+
warn(MODULE, `Failed to read PID file: ${err.message}`);
|
|
60
92
|
}
|
|
61
93
|
return null;
|
|
62
94
|
}
|
|
@@ -68,7 +100,7 @@ export function removeDashboardServerInfo() {
|
|
|
68
100
|
catch (err) {
|
|
69
101
|
const code = err.code;
|
|
70
102
|
if (code !== 'ENOENT') {
|
|
71
|
-
|
|
103
|
+
warn(MODULE, `Failed to remove PID file: ${err.message}`);
|
|
72
104
|
}
|
|
73
105
|
}
|
|
74
106
|
}
|
|
@@ -98,7 +130,7 @@ export async function findRunningDashboardServer() {
|
|
|
98
130
|
catch (err) {
|
|
99
131
|
const code = err.code;
|
|
100
132
|
if (code !== 'ESRCH' && code !== 'EPERM') {
|
|
101
|
-
|
|
133
|
+
warn(MODULE, `Unexpected error checking PID ${info.pid}: ${err.message}`);
|
|
102
134
|
}
|
|
103
135
|
// ESRCH = no process at that PID; EPERM = PID recycled to another user's process
|
|
104
136
|
// Either way, our dashboard server is no longer running — clean up stale PID file
|
|
@@ -130,9 +162,8 @@ function buildDashboardJson(digest, state, commentedIssues) {
|
|
|
130
162
|
monthlyMerged,
|
|
131
163
|
monthlyOpened,
|
|
132
164
|
monthlyClosed,
|
|
133
|
-
activePRs: digest.openPRs || [],
|
|
165
|
+
activePRs: applyStatusOverrides(digest.openPRs || [], state),
|
|
134
166
|
shelvedPRUrls: state.config.shelvedPRUrls || [],
|
|
135
|
-
dismissedUrls: Object.keys(state.config.dismissedIssues || {}),
|
|
136
167
|
recentlyMergedPRs: digest.recentlyMergedPRs || [],
|
|
137
168
|
recentlyClosedPRs: digest.recentlyClosedPRs || [],
|
|
138
169
|
autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
|
|
@@ -170,10 +201,32 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
|
170
201
|
});
|
|
171
202
|
});
|
|
172
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Set security headers on every response.
|
|
206
|
+
*/
|
|
207
|
+
function setSecurityHeaders(res) {
|
|
208
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
209
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
210
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'");
|
|
211
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Validate that POST requests originate from the local dashboard.
|
|
215
|
+
* Returns true if the Origin is acceptable, false otherwise.
|
|
216
|
+
*/
|
|
217
|
+
function isValidOrigin(req, port) {
|
|
218
|
+
const origin = req.headers['origin'];
|
|
219
|
+
if (!origin)
|
|
220
|
+
return true; // No Origin header = same-origin request (non-browser or same-page)
|
|
221
|
+
const allowed = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
|
|
222
|
+
return allowed.includes(origin);
|
|
223
|
+
}
|
|
173
224
|
/**
|
|
174
225
|
* Send a JSON response.
|
|
175
226
|
*/
|
|
176
227
|
function sendJson(res, statusCode, data) {
|
|
228
|
+
setSecurityHeaders(res);
|
|
229
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
177
230
|
const body = JSON.stringify(data);
|
|
178
231
|
res.writeHead(statusCode, {
|
|
179
232
|
'Content-Type': 'application/json',
|
|
@@ -199,9 +252,7 @@ export async function startDashboardServer(options) {
|
|
|
199
252
|
let cachedDigest = stateManager.getState().lastDigest;
|
|
200
253
|
let cachedCommentedIssues = [];
|
|
201
254
|
if (!cachedDigest) {
|
|
202
|
-
|
|
203
|
-
console.error(' GITHUB_TOKEN=$(gh auth token) npm start -- daily');
|
|
204
|
-
process.exit(1);
|
|
255
|
+
throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
|
|
205
256
|
}
|
|
206
257
|
// ── Build cached JSON response ───────────────────────────────────────────
|
|
207
258
|
let cachedJsonData;
|
|
@@ -209,9 +260,7 @@ export async function startDashboardServer(options) {
|
|
|
209
260
|
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
|
|
210
261
|
}
|
|
211
262
|
catch (error) {
|
|
212
|
-
|
|
213
|
-
console.error('Your state data may be corrupted. Try running: daily --json');
|
|
214
|
-
process.exit(1);
|
|
263
|
+
throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
|
|
215
264
|
}
|
|
216
265
|
// ── Request handler ──────────────────────────────────────────────────────
|
|
217
266
|
const server = http.createServer(async (req, res) => {
|
|
@@ -224,10 +273,18 @@ export async function startDashboardServer(options) {
|
|
|
224
273
|
return;
|
|
225
274
|
}
|
|
226
275
|
if (url === '/api/action' && method === 'POST') {
|
|
276
|
+
if (!isValidOrigin(req, actualPort)) {
|
|
277
|
+
sendError(res, 403, 'Invalid origin');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
227
280
|
await handleAction(req, res);
|
|
228
281
|
return;
|
|
229
282
|
}
|
|
230
283
|
if (url === '/api/refresh' && method === 'POST') {
|
|
284
|
+
if (!isValidOrigin(req, actualPort)) {
|
|
285
|
+
sendError(res, 403, 'Invalid origin');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
231
288
|
await handleRefresh(req, res);
|
|
232
289
|
return;
|
|
233
290
|
}
|
|
@@ -239,12 +296,13 @@ export async function startDashboardServer(options) {
|
|
|
239
296
|
sendError(res, 405, 'Method not allowed');
|
|
240
297
|
}
|
|
241
298
|
catch (error) {
|
|
242
|
-
|
|
299
|
+
warn(MODULE, `Unhandled request error: ${method} ${url} ${errorMessage(error)}`);
|
|
243
300
|
if (!res.headersSent) {
|
|
244
301
|
sendError(res, 500, 'Internal server error');
|
|
245
302
|
}
|
|
246
303
|
}
|
|
247
304
|
});
|
|
305
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
248
306
|
// ── POST /api/action handler ─────────────────────────────────────────────
|
|
249
307
|
async function handleAction(req, res) {
|
|
250
308
|
let body;
|
|
@@ -265,76 +323,51 @@ export async function startDashboardServer(options) {
|
|
|
265
323
|
sendError(res, 400, 'Missing or invalid "url" field');
|
|
266
324
|
return;
|
|
267
325
|
}
|
|
268
|
-
// Validate URL format —
|
|
269
|
-
// Dismiss/undismiss accepts both PR and issue URLs; other actions are PR-only.
|
|
270
|
-
const isDismissAction = body.action === 'dismiss' || body.action === 'undismiss';
|
|
271
|
-
const urlPattern = isDismissAction ? ISSUE_OR_PR_URL_PATTERN : PR_URL_PATTERN;
|
|
272
|
-
const urlType = isDismissAction ? 'issue or PR' : 'PR';
|
|
326
|
+
// Validate URL format — all actions are PR-only now.
|
|
273
327
|
try {
|
|
274
328
|
validateUrl(body.url);
|
|
275
|
-
validateGitHubUrl(body.url,
|
|
329
|
+
validateGitHubUrl(body.url, PR_URL_PATTERN, 'PR');
|
|
276
330
|
}
|
|
277
331
|
catch (err) {
|
|
278
332
|
if (err instanceof ValidationError) {
|
|
279
333
|
sendError(res, 400, err.message);
|
|
280
334
|
}
|
|
281
335
|
else {
|
|
282
|
-
|
|
336
|
+
warn(MODULE, `Unexpected error during URL validation: ${errorMessage(err)}`);
|
|
283
337
|
sendError(res, 400, 'Invalid URL');
|
|
284
338
|
}
|
|
285
339
|
return;
|
|
286
340
|
}
|
|
287
|
-
// Validate
|
|
288
|
-
if (body.action === '
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
sendError(res, 400, 'Snooze days must be a positive finite number');
|
|
341
|
+
// Validate override_status-specific fields
|
|
342
|
+
if (body.action === 'override_status') {
|
|
343
|
+
if (!body.status || (body.status !== 'needs_addressing' && body.status !== 'waiting_on_maintainer')) {
|
|
344
|
+
sendError(res, 400, 'override_status requires a valid "status" field (needs_addressing or waiting_on_maintainer)');
|
|
292
345
|
return;
|
|
293
346
|
}
|
|
294
|
-
if (body.reason !== undefined) {
|
|
295
|
-
try {
|
|
296
|
-
validateMessage(String(body.reason));
|
|
297
|
-
}
|
|
298
|
-
catch (err) {
|
|
299
|
-
if (err instanceof ValidationError) {
|
|
300
|
-
sendError(res, 400, err.message);
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
console.error('Unexpected error during message validation:', err);
|
|
304
|
-
sendError(res, 400, 'Invalid reason');
|
|
305
|
-
}
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
347
|
}
|
|
310
348
|
try {
|
|
311
349
|
switch (body.action) {
|
|
312
350
|
case 'shelve':
|
|
313
351
|
stateManager.shelvePR(body.url);
|
|
314
|
-
stateManager.undismissIssue(body.url); // prevent dual state
|
|
315
352
|
break;
|
|
316
353
|
case 'unshelve':
|
|
317
354
|
stateManager.unshelvePR(body.url);
|
|
318
355
|
break;
|
|
319
|
-
case '
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
stateManager.dismissIssue(body.url, new Date().toISOString());
|
|
327
|
-
stateManager.unshelvePR(body.url); // prevent dual state
|
|
328
|
-
break;
|
|
329
|
-
case 'undismiss':
|
|
330
|
-
stateManager.undismissIssue(body.url);
|
|
356
|
+
case 'override_status': {
|
|
357
|
+
// body.status is validated above — the early return ensures it's defined here
|
|
358
|
+
const overrideStatus = body.status;
|
|
359
|
+
// Find the PR to get its current updatedAt for auto-clear tracking
|
|
360
|
+
const targetPR = (cachedDigest?.openPRs || []).find((pr) => pr.url === body.url);
|
|
361
|
+
const lastActivityAt = targetPR?.updatedAt || new Date().toISOString();
|
|
362
|
+
stateManager.setStatusOverride(body.url, overrideStatus, lastActivityAt);
|
|
331
363
|
break;
|
|
364
|
+
}
|
|
332
365
|
}
|
|
333
366
|
stateManager.save();
|
|
334
367
|
}
|
|
335
368
|
catch (error) {
|
|
336
|
-
|
|
337
|
-
sendError(res, 500,
|
|
369
|
+
warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
|
|
370
|
+
sendError(res, 500, 'Action failed');
|
|
338
371
|
return;
|
|
339
372
|
}
|
|
340
373
|
// Rebuild dashboard data from cached digest + updated state
|
|
@@ -349,7 +382,7 @@ export async function startDashboardServer(options) {
|
|
|
349
382
|
return;
|
|
350
383
|
}
|
|
351
384
|
try {
|
|
352
|
-
|
|
385
|
+
warn(MODULE, 'Refreshing dashboard data from GitHub...');
|
|
353
386
|
const result = await fetchDashboardData(currentToken);
|
|
354
387
|
cachedDigest = result.digest;
|
|
355
388
|
cachedCommentedIssues = result.commentedIssues;
|
|
@@ -357,8 +390,8 @@ export async function startDashboardServer(options) {
|
|
|
357
390
|
sendJson(res, 200, cachedJsonData);
|
|
358
391
|
}
|
|
359
392
|
catch (error) {
|
|
360
|
-
|
|
361
|
-
sendError(res, 500,
|
|
393
|
+
warn(MODULE, `Dashboard refresh failed: ${errorMessage(error)}`);
|
|
394
|
+
sendError(res, 500, 'Refresh failed');
|
|
362
395
|
}
|
|
363
396
|
}
|
|
364
397
|
// ── Static file serving ──────────────────────────────────────────────────
|
|
@@ -368,8 +401,8 @@ export async function startDashboardServer(options) {
|
|
|
368
401
|
try {
|
|
369
402
|
urlPath = decodeURIComponent(requestUrl.split('?')[0]);
|
|
370
403
|
}
|
|
371
|
-
catch (
|
|
372
|
-
|
|
404
|
+
catch (_err) {
|
|
405
|
+
warn(MODULE, `Malformed URL received: ${requestUrl}`);
|
|
373
406
|
sendError(res, 400, 'Malformed URL');
|
|
374
407
|
return;
|
|
375
408
|
}
|
|
@@ -399,7 +432,7 @@ export async function startDashboardServer(options) {
|
|
|
399
432
|
filePath = path.join(resolvedAssetsDir, 'index.html');
|
|
400
433
|
}
|
|
401
434
|
else {
|
|
402
|
-
|
|
435
|
+
warn(MODULE, `Failed to stat file: ${filePath}`);
|
|
403
436
|
sendError(res, 500, 'Internal server error');
|
|
404
437
|
return;
|
|
405
438
|
}
|
|
@@ -408,9 +441,11 @@ export async function startDashboardServer(options) {
|
|
|
408
441
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
409
442
|
try {
|
|
410
443
|
const content = fs.readFileSync(filePath);
|
|
444
|
+
setSecurityHeaders(res);
|
|
411
445
|
res.writeHead(200, {
|
|
412
446
|
'Content-Type': contentType,
|
|
413
447
|
'Content-Length': content.length,
|
|
448
|
+
'Cache-Control': 'public, max-age=3600',
|
|
414
449
|
});
|
|
415
450
|
res.end(content);
|
|
416
451
|
}
|
|
@@ -420,7 +455,7 @@ export async function startDashboardServer(options) {
|
|
|
420
455
|
sendError(res, 404, 'Not found');
|
|
421
456
|
}
|
|
422
457
|
else {
|
|
423
|
-
|
|
458
|
+
warn(MODULE, `Failed to serve static file: ${filePath}`);
|
|
424
459
|
sendError(res, 500, 'Failed to read file');
|
|
425
460
|
}
|
|
426
461
|
}
|
|
@@ -440,18 +475,22 @@ export async function startDashboardServer(options) {
|
|
|
440
475
|
catch (err) {
|
|
441
476
|
const nodeErr = err;
|
|
442
477
|
if (nodeErr.code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS - 1) {
|
|
443
|
-
|
|
478
|
+
warn(MODULE, `Port ${actualPort} is in use, trying ${actualPort + 1}...`);
|
|
444
479
|
actualPort++;
|
|
445
480
|
continue;
|
|
446
481
|
}
|
|
447
|
-
|
|
448
|
-
process.exit(1);
|
|
482
|
+
throw new Error(`Failed to start server: ${nodeErr.message}`, { cause: err });
|
|
449
483
|
}
|
|
450
484
|
}
|
|
451
485
|
// Write PID file so other processes can detect this running server
|
|
452
|
-
writeDashboardServerInfo({
|
|
486
|
+
writeDashboardServerInfo({
|
|
487
|
+
pid: process.pid,
|
|
488
|
+
port: actualPort,
|
|
489
|
+
startedAt: new Date().toISOString(),
|
|
490
|
+
version: getCLIVersion(),
|
|
491
|
+
});
|
|
453
492
|
const serverUrl = `http://localhost:${actualPort}`;
|
|
454
|
-
|
|
493
|
+
warn(MODULE, `Dashboard server running at ${serverUrl}`);
|
|
455
494
|
// ── Background refresh ─────────────────────────────────────────────────
|
|
456
495
|
// Port is bound and PID file written — now fetch fresh data from GitHub
|
|
457
496
|
// so subsequent /api/data requests get live data instead of cached state.
|
|
@@ -461,10 +500,10 @@ export async function startDashboardServer(options) {
|
|
|
461
500
|
cachedDigest = result.digest;
|
|
462
501
|
cachedCommentedIssues = result.commentedIssues;
|
|
463
502
|
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
|
|
464
|
-
|
|
503
|
+
warn(MODULE, 'Background data refresh complete');
|
|
465
504
|
})
|
|
466
505
|
.catch((error) => {
|
|
467
|
-
|
|
506
|
+
warn(MODULE, `Background data refresh failed (serving cached data): ${errorMessage(error)}`);
|
|
468
507
|
});
|
|
469
508
|
}
|
|
470
509
|
// ── Open browser ─────────────────────────────────────────────────────────
|
|
@@ -473,7 +512,7 @@ export async function startDashboardServer(options) {
|
|
|
473
512
|
}
|
|
474
513
|
// ── Clean shutdown ───────────────────────────────────────────────────────
|
|
475
514
|
const shutdown = () => {
|
|
476
|
-
|
|
515
|
+
warn(MODULE, 'Shutting down dashboard server...');
|
|
477
516
|
removeDashboardServerInfo();
|
|
478
517
|
server.close(() => {
|
|
479
518
|
process.exit(0);
|
|
@@ -13,36 +13,22 @@ import { generateDashboardScripts } from './dashboard-scripts.js';
|
|
|
13
13
|
export { escapeHtml } from './dashboard-formatters.js';
|
|
14
14
|
export { buildDashboardStats } from './dashboard-data.js';
|
|
15
15
|
export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state, issueResponses = []) {
|
|
16
|
-
const approachingDormantDays = state.config?.approachingDormantDays ?? 25;
|
|
17
16
|
const shelvedPRs = digest.shelvedPRs || [];
|
|
18
17
|
const autoUnshelvedPRs = digest.autoUnshelvedPRs || [];
|
|
19
18
|
const recentlyMerged = digest.recentlyMergedPRs || [];
|
|
20
19
|
const shelvedUrls = new Set(shelvedPRs.map((pr) => pr.url));
|
|
21
20
|
const activePRList = (digest.openPRs || []).filter((pr) => !shelvedUrls.has(pr.url));
|
|
22
21
|
// Action Required: contributor must do something
|
|
23
|
-
const actionRequired = [
|
|
24
|
-
...(digest.prsNeedingResponse || []),
|
|
25
|
-
...(digest.needsChangesPRs || []),
|
|
26
|
-
...(digest.ciFailingPRs || []),
|
|
27
|
-
...(digest.mergeConflictPRs || []),
|
|
28
|
-
...(digest.incompleteChecklistPRs || []),
|
|
29
|
-
...(digest.missingRequiredFilesPRs || []),
|
|
30
|
-
...(digest.needsRebasePRs || []),
|
|
31
|
-
];
|
|
22
|
+
const actionRequired = digest.needsAddressingPRs || [];
|
|
32
23
|
// Waiting on Others: informational, no contributor action needed
|
|
33
|
-
const waitingOnOthers = [
|
|
34
|
-
...(digest.changesAddressedPRs || []),
|
|
35
|
-
...(digest.waitingOnMaintainerPRs || []),
|
|
36
|
-
...(digest.ciBlockedPRs || []),
|
|
37
|
-
...(digest.ciNotRunningPRs || []),
|
|
38
|
-
];
|
|
24
|
+
const waitingOnOthers = digest.waitingOnMaintainerPRs || [];
|
|
39
25
|
return `<!DOCTYPE html>
|
|
40
26
|
<html lang="en">
|
|
41
27
|
<head>
|
|
42
28
|
<meta charset="UTF-8">
|
|
43
29
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
44
30
|
<title>OSS Autopilot - Mission Control</title>
|
|
45
|
-
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
31
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
|
|
46
32
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
47
33
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
48
34
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
@@ -127,22 +113,12 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
127
113
|
<input type="text" class="filter-search" id="searchInput" placeholder="Search by PR title..." />
|
|
128
114
|
<select class="filter-select" id="statusFilter">
|
|
129
115
|
<option value="all">All Statuses</option>
|
|
130
|
-
<option value="needs-
|
|
131
|
-
<option value="
|
|
132
|
-
<option value="ci-failing">CI Failing</option>
|
|
133
|
-
<option value="conflict">Merge Conflict</option>
|
|
134
|
-
<option value="changes-addressed">Changes Addressed</option>
|
|
135
|
-
<option value="waiting-maintainer">Waiting on Maintainer</option>
|
|
136
|
-
<option value="ci-blocked">CI Blocked</option>
|
|
137
|
-
<option value="ci-not-running">CI Not Running</option>
|
|
138
|
-
<option value="incomplete-checklist">Incomplete Checklist</option>
|
|
139
|
-
<option value="missing-files">Missing Files</option>
|
|
140
|
-
<option value="needs-rebase">Needs Rebase</option>
|
|
116
|
+
<option value="needs-addressing">Needs Addressing</option>
|
|
117
|
+
<option value="waiting-on-maintainer">Waiting on Maintainer</option>
|
|
141
118
|
<option value="shelved">Shelved</option>
|
|
142
119
|
<option value="merged">Recently Merged</option>
|
|
143
120
|
<option value="closed">Recently Closed</option>
|
|
144
121
|
<option value="auto-unshelved">Auto-Unshelved</option>
|
|
145
|
-
<option value="active">Active (No Issues)</option>
|
|
146
122
|
</select>
|
|
147
123
|
<select class="filter-select" id="repoFilter">
|
|
148
124
|
<option value="all">All Repositories</option>
|
|
@@ -184,15 +160,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
184
160
|
<span class="health-badge">${actionRequired.length} issue${actionRequired.length !== 1 ? 's' : ''}</span>
|
|
185
161
|
</div>
|
|
186
162
|
<div class="health-items">
|
|
187
|
-
${renderHealthItems(
|
|
188
|
-
? `@${escapeHtml(pr.lastMaintainerComment.author)}: ${truncateTitle(pr.lastMaintainerComment.body, 40)}`
|
|
189
|
-
: truncateTitle(pr.title))}
|
|
190
|
-
${renderHealthItems(digest.needsChangesPRs || [], 'needs-changes', SVG_ICONS.edit, 'Needs Changes', titleMeta)}
|
|
191
|
-
${renderHealthItems(digest.ciFailingPRs || [], 'ci-failing', SVG_ICONS.xCircle, 'CI Failing', titleMeta)}
|
|
192
|
-
${renderHealthItems(digest.mergeConflictPRs || [], 'conflict', SVG_ICONS.conflict, 'Merge Conflict', titleMeta)}
|
|
193
|
-
${renderHealthItems(digest.incompleteChecklistPRs || [], 'incomplete-checklist', SVG_ICONS.checklist, (pr) => `Incomplete Checklist${pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : ''}`, titleMeta)}
|
|
194
|
-
${renderHealthItems(digest.missingRequiredFilesPRs || [], 'missing-files', SVG_ICONS.file, 'Missing Required Files', (pr) => (pr.missingRequiredFiles ? escapeHtml(pr.missingRequiredFiles.join(', ')) : truncateTitle(pr.title)))}
|
|
195
|
-
${renderHealthItems(digest.needsRebasePRs || [], 'needs-rebase', SVG_ICONS.refresh, (pr) => `Needs Rebase${pr.commitsBehindUpstream ? ` (${pr.commitsBehindUpstream} behind)` : ''}`, titleMeta)}
|
|
163
|
+
${renderHealthItems(actionRequired, 'needs-addressing', SVG_ICONS.xCircle, (pr) => pr.displayLabel, (pr) => escapeHtml(pr.displayDescription))}
|
|
196
164
|
</div>
|
|
197
165
|
</section>
|
|
198
166
|
`
|
|
@@ -210,10 +178,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
210
178
|
<span class="health-badge" style="background: var(--accent-info-dim); color: var(--accent-info);">${waitingOnOthers.length} PR${waitingOnOthers.length !== 1 ? 's' : ''}</span>
|
|
211
179
|
</div>
|
|
212
180
|
<div class="health-items">
|
|
213
|
-
${renderHealthItems(
|
|
214
|
-
${renderHealthItems(digest.waitingOnMaintainerPRs || [], 'waiting-maintainer', SVG_ICONS.clock, 'Waiting on Maintainer', titleMeta)}
|
|
215
|
-
${renderHealthItems(digest.ciBlockedPRs || [], 'ci-blocked', SVG_ICONS.lock, 'CI Blocked', titleMeta)}
|
|
216
|
-
${renderHealthItems(digest.ciNotRunningPRs || [], 'ci-not-running', SVG_ICONS.infoCircle, 'CI Not Running', titleMeta)}
|
|
181
|
+
${renderHealthItems(waitingOnOthers, 'waiting-on-maintainer', SVG_ICONS.clock, (pr) => pr.displayLabel, (pr) => escapeHtml(pr.displayDescription))}
|
|
217
182
|
</div>
|
|
218
183
|
</section>
|
|
219
184
|
`
|
|
@@ -230,7 +195,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
230
195
|
<h2>Health Status</h2>
|
|
231
196
|
</div>
|
|
232
197
|
<div class="health-empty">
|
|
233
|
-
All PRs are
|
|
198
|
+
All PRs are on track - no CI failures, conflicts, or pending responses
|
|
234
199
|
</div>
|
|
235
200
|
</section>
|
|
236
201
|
`
|
|
@@ -313,7 +278,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
313
278
|
<span class="health-badge" style="background: var(--accent-info-dim); color: var(--accent-info);">${autoUnshelvedPRs.length} unshelved</span>
|
|
314
279
|
</div>
|
|
315
280
|
<div class="health-items">
|
|
316
|
-
${renderHealthItems(autoUnshelvedPRs, 'auto-unshelved', SVG_ICONS.bell, (pr) =>
|
|
281
|
+
${renderHealthItems(autoUnshelvedPRs, 'auto-unshelved', SVG_ICONS.bell, (pr) => `Auto-Unshelved (${pr.status.replace(/_/g, ' ')})`, titleMeta)}
|
|
317
282
|
</div>
|
|
318
283
|
</section>
|
|
319
284
|
`
|
|
@@ -396,27 +361,14 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
396
361
|
<div class="pr-list">
|
|
397
362
|
${activePRList
|
|
398
363
|
.map((pr) => {
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const isStale = pr.daysSinceActivity >= approachingDormantDays;
|
|
404
|
-
const itemClass = hasIssues ? 'has-issues' : isStale ? 'stale' : '';
|
|
405
|
-
const prStatus = pr.ciStatus === 'failing'
|
|
406
|
-
? 'ci-failing'
|
|
407
|
-
: pr.hasMergeConflict
|
|
408
|
-
? 'conflict'
|
|
409
|
-
: pr.hasUnrespondedComment && pr.status !== 'changes_addressed' && pr.status !== 'failing_ci'
|
|
410
|
-
? 'needs-response'
|
|
411
|
-
: pr.status === 'needs_changes'
|
|
412
|
-
? 'needs-changes'
|
|
413
|
-
: pr.status === 'changes_addressed'
|
|
414
|
-
? 'changes-addressed'
|
|
415
|
-
: 'active';
|
|
364
|
+
const isNeedsAddressing = pr.status === 'needs_addressing';
|
|
365
|
+
const isStale = pr.stalenessTier !== 'active';
|
|
366
|
+
const itemClass = isNeedsAddressing ? 'has-issues' : isStale ? 'stale' : '';
|
|
367
|
+
const prStatus = pr.status === 'needs_addressing' ? 'needs-addressing' : 'waiting-on-maintainer';
|
|
416
368
|
return `
|
|
417
369
|
<div class="pr-item ${itemClass}" data-status="${prStatus}" data-repo="${escapeHtml(pr.repo)}" data-title="${escapeHtml(pr.title.toLowerCase())}">
|
|
418
370
|
<div class="pr-status-indicator">
|
|
419
|
-
${
|
|
371
|
+
${isNeedsAddressing
|
|
420
372
|
? `
|
|
421
373
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
422
374
|
<circle cx="12" cy="12" r="10"/>
|
|
@@ -438,13 +390,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
|
|
|
438
390
|
<span class="pr-repo">${escapeHtml(pr.repo)}#${pr.number}</span>
|
|
439
391
|
</div>
|
|
440
392
|
<div class="pr-badges">
|
|
441
|
-
|
|
442
|
-
${pr.ciStatus === 'passing' ? '<span class="badge badge-passing">CI Passing</span>' : ''}
|
|
443
|
-
${pr.ciStatus === 'pending' ? '<span class="badge badge-pending">CI Pending</span>' : ''}
|
|
444
|
-
${pr.hasMergeConflict ? '<span class="badge badge-conflict">Merge Conflict</span>' : ''}
|
|
445
|
-
${pr.hasUnrespondedComment && pr.status === 'changes_addressed' ? '<span class="badge badge-changes-addressed">Changes Addressed</span>' : ''}
|
|
446
|
-
${pr.hasUnrespondedComment && pr.status !== 'changes_addressed' && pr.status !== 'failing_ci' ? '<span class="badge badge-needs-response">Needs Response</span>' : ''}
|
|
447
|
-
${pr.reviewDecision === 'changes_requested' ? '<span class="badge badge-changes-requested">Changes Requested</span>' : ''}
|
|
393
|
+
<span class="badge ${isNeedsAddressing ? 'badge-ci-failing' : 'badge-passing'}">${escapeHtml(pr.displayLabel)}</span>
|
|
448
394
|
${isStale ? `<span class="badge badge-stale">${pr.daysSinceActivity}d inactive</span>` : ''}
|
|
449
395
|
</div>
|
|
450
396
|
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override command
|
|
3
|
+
* Manually override a PR's status (needs_addressing ↔ waiting_on_maintainer).
|
|
4
|
+
* Overrides auto-clear when the PR has new activity.
|
|
5
|
+
*/
|
|
6
|
+
import type { FetchedPRStatus } from '../core/types.js';
|
|
7
|
+
export interface OverrideOutput {
|
|
8
|
+
url: string;
|
|
9
|
+
status: FetchedPRStatus;
|
|
10
|
+
}
|
|
11
|
+
export interface ClearOverrideOutput {
|
|
12
|
+
url: string;
|
|
13
|
+
cleared: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function runOverride(options: {
|
|
16
|
+
prUrl: string;
|
|
17
|
+
status: string;
|
|
18
|
+
}): Promise<OverrideOutput>;
|
|
19
|
+
export declare function runClearOverride(options: {
|
|
20
|
+
prUrl: string;
|
|
21
|
+
}): Promise<ClearOverrideOutput>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override command
|
|
3
|
+
* Manually override a PR's status (needs_addressing ↔ waiting_on_maintainer).
|
|
4
|
+
* Overrides auto-clear when the PR has new activity.
|
|
5
|
+
*/
|
|
6
|
+
import { getStateManager } from '../core/index.js';
|
|
7
|
+
import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
|
|
8
|
+
const VALID_STATUSES = ['needs_addressing', 'waiting_on_maintainer'];
|
|
9
|
+
export async function runOverride(options) {
|
|
10
|
+
validateUrl(options.prUrl);
|
|
11
|
+
validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
|
|
12
|
+
if (!VALID_STATUSES.includes(options.status)) {
|
|
13
|
+
throw new Error(`Invalid status "${options.status}". Must be one of: ${VALID_STATUSES.join(', ')}`);
|
|
14
|
+
}
|
|
15
|
+
const status = options.status;
|
|
16
|
+
const stateManager = getStateManager();
|
|
17
|
+
// Use current time as lastActivityAt — the CLI doesn't have cached PR data.
|
|
18
|
+
// This means the override will auto-clear on the next daily run if the PR's
|
|
19
|
+
// updatedAt is after this timestamp (which is the desired behavior: the override
|
|
20
|
+
// will persist until new activity occurs on the PR).
|
|
21
|
+
const lastActivityAt = new Date().toISOString();
|
|
22
|
+
stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
|
|
23
|
+
stateManager.save();
|
|
24
|
+
return { url: options.prUrl, status };
|
|
25
|
+
}
|
|
26
|
+
export async function runClearOverride(options) {
|
|
27
|
+
validateUrl(options.prUrl);
|
|
28
|
+
validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
|
|
29
|
+
const stateManager = getStateManager();
|
|
30
|
+
const cleared = stateManager.clearStatusOverride(options.prUrl);
|
|
31
|
+
if (cleared) {
|
|
32
|
+
stateManager.save();
|
|
33
|
+
}
|
|
34
|
+
return { url: options.prUrl, cleared };
|
|
35
|
+
}
|
|
@@ -32,8 +32,10 @@ export function analyzeChecklist(body) {
|
|
|
32
32
|
return { hasIncompleteChecklist: false };
|
|
33
33
|
// Filter out conditional checklist items that are intentionally unchecked
|
|
34
34
|
const nonConditionalUnchecked = uncheckedLines.filter((line) => !isConditionalChecklistItem(line));
|
|
35
|
+
// Use consistent total that excludes conditional items (matches hasIncompleteChecklist logic)
|
|
36
|
+
const effectiveTotal = checked + nonConditionalUnchecked.length;
|
|
35
37
|
return {
|
|
36
38
|
hasIncompleteChecklist: nonConditionalUnchecked.length > 0,
|
|
37
|
-
checklistStats: { checked, total },
|
|
39
|
+
checklistStats: { checked, total: effectiveTotal },
|
|
38
40
|
};
|
|
39
41
|
}
|