@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.
Files changed (43) hide show
  1. package/dist/cli-registry.js +61 -0
  2. package/dist/cli.bundle.cjs +101 -127
  3. package/dist/cli.bundle.cjs.map +4 -4
  4. package/dist/commands/daily.d.ts +6 -1
  5. package/dist/commands/daily.js +29 -64
  6. package/dist/commands/dashboard-data.d.ts +22 -1
  7. package/dist/commands/dashboard-data.js +85 -62
  8. package/dist/commands/dashboard-lifecycle.js +39 -2
  9. package/dist/commands/dashboard-scripts.d.ts +1 -1
  10. package/dist/commands/dashboard-scripts.js +2 -1
  11. package/dist/commands/dashboard-server.d.ts +2 -1
  12. package/dist/commands/dashboard-server.js +120 -81
  13. package/dist/commands/dashboard-templates.js +15 -69
  14. package/dist/commands/override.d.ts +21 -0
  15. package/dist/commands/override.js +35 -0
  16. package/dist/core/checklist-analysis.js +3 -1
  17. package/dist/core/daily-logic.d.ts +13 -10
  18. package/dist/core/daily-logic.js +79 -166
  19. package/dist/core/display-utils.d.ts +4 -0
  20. package/dist/core/display-utils.js +53 -54
  21. package/dist/core/errors.d.ts +8 -0
  22. package/dist/core/errors.js +26 -0
  23. package/dist/core/github-stats.d.ts +3 -3
  24. package/dist/core/github-stats.js +15 -7
  25. package/dist/core/index.d.ts +2 -2
  26. package/dist/core/index.js +2 -2
  27. package/dist/core/issue-conversation.js +2 -2
  28. package/dist/core/issue-discovery.d.ts +0 -5
  29. package/dist/core/issue-discovery.js +4 -11
  30. package/dist/core/issue-vetting.d.ts +0 -2
  31. package/dist/core/issue-vetting.js +31 -45
  32. package/dist/core/pr-monitor.d.ts +26 -3
  33. package/dist/core/pr-monitor.js +106 -93
  34. package/dist/core/state.d.ts +22 -1
  35. package/dist/core/state.js +50 -1
  36. package/dist/core/test-utils.js +6 -16
  37. package/dist/core/types.d.ts +51 -38
  38. package/dist/core/types.js +8 -0
  39. package/dist/core/utils.d.ts +2 -0
  40. package/dist/core/utils.js +5 -1
  41. package/dist/formatters/json.d.ts +1 -13
  42. package/dist/formatters/json.js +1 -13
  43. 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, snooze, etc.).
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 { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, } from './validation.js';
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
- 'shelve',
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
- console.error('[DASHBOARD] PID file has invalid structure, ignoring');
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
- console.error(`[DASHBOARD] Failed to read PID file: ${err.message}`);
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
- console.error(`[DASHBOARD] Failed to remove PID file: ${err.message}`);
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
- console.error(`[DASHBOARD] Unexpected error checking PID ${info.pid}: ${err.message}`);
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
- console.error('No dashboard data available. Run the daily check first:');
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
- console.error('Failed to build dashboard data from cached digest:', error);
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
- console.error('Unhandled request error:', method, url, error);
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 — same checks as CLI commands.
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, urlPattern, urlType);
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
- console.error('Unexpected error during URL validation:', err);
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 snooze-specific fields
288
- if (body.action === 'snooze') {
289
- const days = body.days ?? 7;
290
- if (typeof days !== 'number' || !Number.isFinite(days) || days <= 0) {
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 'snooze':
320
- stateManager.snoozePR(body.url, body.reason || 'Snoozed via dashboard', body.days ?? 7);
321
- break;
322
- case 'unsnooze':
323
- stateManager.unsnoozePR(body.url);
324
- break;
325
- case 'dismiss':
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
- console.error('Action failed:', body.action, body.url, error);
337
- sendError(res, 500, `Action failed: ${errorMessage(error)}`);
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
- console.error('Refreshing dashboard data from GitHub...');
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
- console.error('Dashboard refresh failed:', error);
361
- sendError(res, 500, `Refresh failed: ${errorMessage(error)}`);
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 (err) {
372
- console.error('Malformed URL received:', requestUrl, err);
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
- console.error('Failed to stat file:', filePath, err);
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
- console.error('Failed to serve static file:', filePath, error);
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
- console.error(`Port ${actualPort} is in use, trying ${actualPort + 1}...`);
478
+ warn(MODULE, `Port ${actualPort} is in use, trying ${actualPort + 1}...`);
444
479
  actualPort++;
445
480
  continue;
446
481
  }
447
- console.error(`Failed to start server: ${nodeErr.message}`);
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({ pid: process.pid, port: actualPort, startedAt: new Date().toISOString() });
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
- console.error(`Dashboard server running at ${serverUrl}`);
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
- console.error('Background data refresh complete');
503
+ warn(MODULE, 'Background data refresh complete');
465
504
  })
466
505
  .catch((error) => {
467
- console.error('Background data refresh failed (serving cached data):', errorMessage(error));
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
- console.error('\nShutting down dashboard server...');
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-response">Needs Response</option>
131
- <option value="needs-changes">Needs Changes</option>
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(digest.prsNeedingResponse || [], 'needs-response', SVG_ICONS.comment, 'Needs Response', (pr) => pr.lastMaintainerComment
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(digest.changesAddressedPRs || [], 'changes-addressed', SVG_ICONS.checkCircle, 'Changes Addressed', (pr) => `Awaiting re-review${pr.lastMaintainerComment ? ` from @${escapeHtml(pr.lastMaintainerComment.author)}` : ''}`)}
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 healthy - no CI failures, conflicts, or pending responses
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) => 'Auto-Unshelved (' + pr.status.replace(/_/g, ' ') + ')', titleMeta)}
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 hasIssues = pr.ciStatus === 'failing' ||
400
- pr.hasMergeConflict ||
401
- (pr.hasUnrespondedComment && pr.status !== 'changes_addressed') ||
402
- pr.status === 'needs_changes';
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
- ${hasIssues
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
- ${pr.ciStatus === 'failing' ? '<span class="badge badge-ci-failing">CI Failing</span>' : ''}
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
  }