@oss-autopilot/core 0.44.1 → 0.44.3

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.
@@ -7,22 +7,12 @@
7
7
  * orchestration layer that wires up the phases and handles I/O.
8
8
  */
9
9
  import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
10
- import { errorMessage, getHttpStatusCode } from '../core/errors.js';
10
+ import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
11
  import { warn } from '../core/logger.js';
12
12
  import { emptyPRCountsResult } from '../core/github-stats.js';
13
+ import { updateMonthlyAnalytics } from './dashboard-data.js';
13
14
  import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
14
15
  const MODULE = 'daily';
15
- /** Return true for errors that should propagate (not degrade gracefully). */
16
- function isRateLimitOrAuthError(err) {
17
- const status = getHttpStatusCode(err);
18
- if (status === 401 || status === 429)
19
- return true;
20
- if (status === 403) {
21
- const msg = errorMessage(err).toLowerCase();
22
- return msg.includes('rate limit') || msg.includes('abuse detection');
23
- }
24
- return false;
25
- }
26
16
  // Re-export domain functions so existing consumers (tests, dashboard, startup)
27
17
  // can continue importing from './daily.js' without changes.
28
18
  export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
@@ -218,53 +208,6 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
218
208
  warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
219
209
  }
220
210
  }
221
- /**
222
- * Phase 3: Persist monthly chart analytics to state.
223
- * Stores merged, closed, and combined opened counts per month.
224
- * Each metric is isolated so partial failures don't produce inconsistent state.
225
- */
226
- function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
227
- const stateManager = getStateManager();
228
- // Store monthly chart data (non-critical — each metric isolated so partial failures don't leave inconsistent state).
229
- // Guard: skip overwriting when the data is empty to avoid wiping existing chart data on transient API failures.
230
- // An empty object means the fetch failed and fell back to emptyPRCountsResult(), so we preserve previous state.
231
- try {
232
- if (Object.keys(monthlyCounts).length > 0) {
233
- stateManager.setMonthlyMergedCounts(monthlyCounts);
234
- }
235
- }
236
- catch (error) {
237
- warn(MODULE, `Failed to store monthly merged counts: ${errorMessage(error)}`);
238
- }
239
- try {
240
- if (Object.keys(monthlyClosedCounts).length > 0) {
241
- stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
242
- }
243
- }
244
- catch (error) {
245
- warn(MODULE, `Failed to store monthly closed counts: ${errorMessage(error)}`);
246
- }
247
- try {
248
- // Build combined monthly opened counts from merged + closed + currently-open PRs
249
- const combinedOpenedCounts = { ...openedFromMerged };
250
- for (const [month, count] of Object.entries(openedFromClosed)) {
251
- combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + count;
252
- }
253
- // Add currently-open PR creation dates
254
- for (const pr of prs) {
255
- if (pr.createdAt) {
256
- const month = pr.createdAt.slice(0, 7);
257
- combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
258
- }
259
- }
260
- if (Object.keys(combinedOpenedCounts).length > 0) {
261
- stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
262
- }
263
- }
264
- catch (error) {
265
- warn(MODULE, `Failed to compute/store monthly opened counts: ${errorMessage(error)}`);
266
- }
267
- }
268
211
  /**
269
212
  * Phase 4: Expire snoozes and partition PRs into active vs shelved buckets.
270
213
  * Auto-unshelves PRs where maintainers have engaged, generates the digest,
@@ -459,7 +402,7 @@ async function executeDailyCheckInternal(token) {
459
402
  // Phase 2: Update repo scores (signals, star counts, trust sync)
460
403
  await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
461
404
  // Phase 3: Persist monthly analytics
462
- updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
405
+ updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
463
406
  // Phase 4: Expire snoozes, partition PRs, generate and save digest
464
407
  const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs);
465
408
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
@@ -12,10 +12,23 @@ export interface DashboardStats {
12
12
  mergeRate: string;
13
13
  }
14
14
  export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>): DashboardStats;
15
+ /**
16
+ * Persist monthly chart analytics (merged, closed, opened) to state.
17
+ * Each metric is isolated so partial failures don't produce inconsistent state.
18
+ * Skips overwriting when data is empty to avoid wiping chart data on transient API failures.
19
+ */
20
+ export declare function updateMonthlyAnalytics(prs: Array<{
21
+ createdAt?: string;
22
+ }>, monthlyCounts: Record<string, number>, monthlyClosedCounts: Record<string, number>, openedFromMerged: Record<string, number>, openedFromClosed: Record<string, number>): void;
15
23
  export interface DashboardFetchResult {
16
24
  digest: DailyDigest;
17
25
  commentedIssues: CommentedIssue[];
18
26
  }
27
+ /**
28
+ * Fetch fresh dashboard data from GitHub.
29
+ * Returns the digest and commented issues, updating state as a side effect.
30
+ * Throws if the fetch fails entirely (caller should fall back to cached data).
31
+ */
19
32
  export declare function fetchDashboardData(token: string): Promise<DashboardFetchResult>;
20
33
  /**
21
34
  * Compute PRs grouped by repository from a digest and state.
@@ -4,9 +4,11 @@
4
4
  * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
6
  import { getStateManager, PRMonitor, IssueConversationMonitor } from '../core/index.js';
7
- import { errorMessage, getHttpStatusCode } from '../core/errors.js';
7
+ import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
+ import { warn } from '../core/logger.js';
8
9
  import { emptyPRCountsResult } from '../core/github-stats.js';
9
10
  import { toShelvedPRRef } from './daily.js';
11
+ const MODULE = 'dashboard-data';
10
12
  export function buildDashboardStats(digest, state) {
11
13
  const summary = digest.summary || {
12
14
  totalActivePRs: 0,
@@ -22,21 +24,53 @@ export function buildDashboardStats(digest, state) {
22
24
  mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
23
25
  };
24
26
  }
27
+ /**
28
+ * Persist monthly chart analytics (merged, closed, opened) to state.
29
+ * Each metric is isolated so partial failures don't produce inconsistent state.
30
+ * Skips overwriting when data is empty to avoid wiping chart data on transient API failures.
31
+ */
32
+ export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
33
+ const stateManager = getStateManager();
34
+ try {
35
+ if (Object.keys(monthlyCounts).length > 0) {
36
+ stateManager.setMonthlyMergedCounts(monthlyCounts);
37
+ }
38
+ }
39
+ catch (error) {
40
+ warn(MODULE, `Failed to store monthly merged counts: ${errorMessage(error)}`);
41
+ }
42
+ try {
43
+ if (Object.keys(monthlyClosedCounts).length > 0) {
44
+ stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
45
+ }
46
+ }
47
+ catch (error) {
48
+ warn(MODULE, `Failed to store monthly closed counts: ${errorMessage(error)}`);
49
+ }
50
+ try {
51
+ const combinedOpenedCounts = { ...openedFromMerged };
52
+ for (const [month, count] of Object.entries(openedFromClosed)) {
53
+ combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + count;
54
+ }
55
+ for (const pr of prs) {
56
+ if (pr.createdAt) {
57
+ const month = pr.createdAt.slice(0, 7);
58
+ combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
59
+ }
60
+ }
61
+ if (Object.keys(combinedOpenedCounts).length > 0) {
62
+ stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
63
+ }
64
+ }
65
+ catch (error) {
66
+ warn(MODULE, `Failed to store monthly opened counts: ${errorMessage(error)}`);
67
+ }
68
+ }
25
69
  /**
26
70
  * Fetch fresh dashboard data from GitHub.
27
71
  * Returns the digest and commented issues, updating state as a side effect.
28
72
  * Throws if the fetch fails entirely (caller should fall back to cached data).
29
73
  */
30
- function isRateLimitOrAuthError(err) {
31
- const status = getHttpStatusCode(err);
32
- if (status === 401 || status === 429)
33
- return true;
34
- if (status === 403) {
35
- const msg = errorMessage(err).toLowerCase();
36
- return msg.includes('rate limit') || msg.includes('abuse detection');
37
- }
38
- return false;
39
- }
40
74
  export async function fetchDashboardData(token) {
41
75
  const stateManager = getStateManager();
42
76
  const prMonitor = new PRMonitor(token);
@@ -46,34 +80,34 @@ export async function fetchDashboardData(token) {
46
80
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
47
81
  if (isRateLimitOrAuthError(err))
48
82
  throw err;
49
- console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
83
+ warn(MODULE, `Failed to fetch recently closed PRs: ${errorMessage(err)}`);
50
84
  return [];
51
85
  }),
52
86
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
53
87
  if (isRateLimitOrAuthError(err))
54
88
  throw err;
55
- console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
89
+ warn(MODULE, `Failed to fetch recently merged PRs: ${errorMessage(err)}`);
56
90
  return [];
57
91
  }),
58
92
  prMonitor.fetchUserMergedPRCounts().catch((err) => {
59
93
  if (isRateLimitOrAuthError(err))
60
94
  throw err;
61
- console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
95
+ warn(MODULE, `Failed to fetch merged PR counts: ${errorMessage(err)}`);
62
96
  return emptyPRCountsResult();
63
97
  }),
64
98
  prMonitor.fetchUserClosedPRCounts().catch((err) => {
65
99
  if (isRateLimitOrAuthError(err))
66
100
  throw err;
67
- console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
101
+ warn(MODULE, `Failed to fetch closed PR counts: ${errorMessage(err)}`);
68
102
  return emptyPRCountsResult();
69
103
  }),
70
104
  issueMonitor.fetchCommentedIssues().catch((error) => {
71
105
  const msg = errorMessage(error);
72
106
  if (msg.includes('No GitHub username configured')) {
73
- console.error(`[DASHBOARD] Issue conversation tracking requires setup: ${msg}`);
107
+ warn(MODULE, `Issue conversation tracking requires setup: ${msg}`);
74
108
  }
75
109
  else {
76
- console.error(`[DASHBOARD] Issue conversation fetch failed: ${msg}`);
110
+ warn(MODULE, `Issue conversation fetch failed: ${msg}`);
77
111
  }
78
112
  return {
79
113
  issues: [],
@@ -83,49 +117,15 @@ export async function fetchDashboardData(token) {
83
117
  ]);
84
118
  const commentedIssues = fetchedIssues.issues;
85
119
  if (fetchedIssues.failures.length > 0) {
86
- console.error(`[DASHBOARD] ${fetchedIssues.failures.length} issue conversation check(s) failed`);
120
+ warn(MODULE, `${fetchedIssues.failures.length} issue conversation check(s) failed`);
87
121
  }
88
122
  if (failures.length > 0) {
89
- console.error(`Warning: ${failures.length} PR fetch(es) failed`);
123
+ warn(MODULE, `${failures.length} PR fetch(es) failed`);
90
124
  }
91
125
  // Store monthly chart data (opened/merged/closed) so charts have data
92
126
  const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
93
127
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
94
- // Guard: skip overwriting when data is empty to avoid wiping chart data on transient API failures.
95
- try {
96
- if (Object.keys(monthlyCounts).length > 0) {
97
- stateManager.setMonthlyMergedCounts(monthlyCounts);
98
- }
99
- }
100
- catch (error) {
101
- console.error('[DASHBOARD] Failed to store monthly merged counts:', errorMessage(error));
102
- }
103
- try {
104
- if (Object.keys(monthlyClosedCounts).length > 0) {
105
- stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
106
- }
107
- }
108
- catch (error) {
109
- console.error('[DASHBOARD] Failed to store monthly closed counts:', errorMessage(error));
110
- }
111
- try {
112
- const combinedOpenedCounts = { ...openedFromMerged };
113
- for (const [month, count] of Object.entries(openedFromClosed)) {
114
- combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + count;
115
- }
116
- for (const pr of prs) {
117
- if (pr.createdAt) {
118
- const month = pr.createdAt.slice(0, 7);
119
- combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
120
- }
121
- }
122
- if (Object.keys(combinedOpenedCounts).length > 0) {
123
- stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
124
- }
125
- }
126
- catch (error) {
127
- console.error('[DASHBOARD] Failed to store monthly opened counts:', errorMessage(error));
128
- }
128
+ updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
129
129
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
130
130
  // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
131
131
  // Dormant PRs are treated as shelved for display purposes
@@ -139,9 +139,9 @@ export async function fetchDashboardData(token) {
139
139
  stateManager.save();
140
140
  }
141
141
  catch (error) {
142
- console.error('Warning: Failed to save dashboard digest to state:', errorMessage(error));
142
+ warn(MODULE, `Failed to save dashboard digest to state: ${errorMessage(error)}`);
143
143
  }
144
- console.error(`Refreshed: ${prs.length} PRs fetched`);
144
+ warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
145
145
  return { digest, commentedIssues };
146
146
  }
147
147
  /**
@@ -10,6 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { getStateManager, getGitHubToken, getDataDir } from '../core/index.js';
12
12
  import { errorMessage, ValidationError } from '../core/errors.js';
13
+ import { warn } from '../core/logger.js';
13
14
  import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN, ISSUE_OR_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';
@@ -22,7 +23,9 @@ const VALID_ACTIONS = new Set([
22
23
  'dismiss',
23
24
  'undismiss',
24
25
  ]);
26
+ const MODULE = 'dashboard-server';
25
27
  const MAX_BODY_BYTES = 10_240;
28
+ const REQUEST_TIMEOUT_MS = 30_000;
26
29
  const MIME_TYPES = {
27
30
  '.html': 'text/html',
28
31
  '.js': 'application/javascript',
@@ -48,7 +51,7 @@ export function readDashboardServerInfo() {
48
51
  typeof parsed.pid !== 'number' ||
49
52
  typeof parsed.port !== 'number' ||
50
53
  typeof parsed.startedAt !== 'string') {
51
- console.error('[DASHBOARD] PID file has invalid structure, ignoring');
54
+ warn(MODULE, 'PID file has invalid structure, ignoring');
52
55
  return null;
53
56
  }
54
57
  return parsed;
@@ -56,7 +59,7 @@ export function readDashboardServerInfo() {
56
59
  catch (err) {
57
60
  const code = err.code;
58
61
  if (code !== 'ENOENT') {
59
- console.error(`[DASHBOARD] Failed to read PID file: ${err.message}`);
62
+ warn(MODULE, `Failed to read PID file: ${err.message}`);
60
63
  }
61
64
  return null;
62
65
  }
@@ -68,7 +71,7 @@ export function removeDashboardServerInfo() {
68
71
  catch (err) {
69
72
  const code = err.code;
70
73
  if (code !== 'ENOENT') {
71
- console.error(`[DASHBOARD] Failed to remove PID file: ${err.message}`);
74
+ warn(MODULE, `Failed to remove PID file: ${err.message}`);
72
75
  }
73
76
  }
74
77
  }
@@ -98,7 +101,7 @@ export async function findRunningDashboardServer() {
98
101
  catch (err) {
99
102
  const code = err.code;
100
103
  if (code !== 'ESRCH' && code !== 'EPERM') {
101
- console.error(`[DASHBOARD] Unexpected error checking PID ${info.pid}: ${err.message}`);
104
+ warn(MODULE, `Unexpected error checking PID ${info.pid}: ${err.message}`);
102
105
  }
103
106
  // ESRCH = no process at that PID; EPERM = PID recycled to another user's process
104
107
  // Either way, our dashboard server is no longer running — clean up stale PID file
@@ -170,10 +173,32 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
170
173
  });
171
174
  });
172
175
  }
176
+ /**
177
+ * Set security headers on every response.
178
+ */
179
+ function setSecurityHeaders(res) {
180
+ res.setHeader('X-Content-Type-Options', 'nosniff');
181
+ res.setHeader('X-Frame-Options', 'DENY');
182
+ 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'");
183
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
184
+ }
185
+ /**
186
+ * Validate that POST requests originate from the local dashboard.
187
+ * Returns true if the Origin is acceptable, false otherwise.
188
+ */
189
+ function isValidOrigin(req, port) {
190
+ const origin = req.headers['origin'];
191
+ if (!origin)
192
+ return true; // No Origin header = same-origin request (non-browser or same-page)
193
+ const allowed = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
194
+ return allowed.includes(origin);
195
+ }
173
196
  /**
174
197
  * Send a JSON response.
175
198
  */
176
199
  function sendJson(res, statusCode, data) {
200
+ setSecurityHeaders(res);
201
+ res.setHeader('Cache-Control', 'no-store');
177
202
  const body = JSON.stringify(data);
178
203
  res.writeHead(statusCode, {
179
204
  'Content-Type': 'application/json',
@@ -199,9 +224,7 @@ export async function startDashboardServer(options) {
199
224
  let cachedDigest = stateManager.getState().lastDigest;
200
225
  let cachedCommentedIssues = [];
201
226
  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);
227
+ throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
205
228
  }
206
229
  // ── Build cached JSON response ───────────────────────────────────────────
207
230
  let cachedJsonData;
@@ -209,9 +232,7 @@ export async function startDashboardServer(options) {
209
232
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
210
233
  }
211
234
  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);
235
+ throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
215
236
  }
216
237
  // ── Request handler ──────────────────────────────────────────────────────
217
238
  const server = http.createServer(async (req, res) => {
@@ -224,10 +245,18 @@ export async function startDashboardServer(options) {
224
245
  return;
225
246
  }
226
247
  if (url === '/api/action' && method === 'POST') {
248
+ if (!isValidOrigin(req, actualPort)) {
249
+ sendError(res, 403, 'Invalid origin');
250
+ return;
251
+ }
227
252
  await handleAction(req, res);
228
253
  return;
229
254
  }
230
255
  if (url === '/api/refresh' && method === 'POST') {
256
+ if (!isValidOrigin(req, actualPort)) {
257
+ sendError(res, 403, 'Invalid origin');
258
+ return;
259
+ }
231
260
  await handleRefresh(req, res);
232
261
  return;
233
262
  }
@@ -239,12 +268,13 @@ export async function startDashboardServer(options) {
239
268
  sendError(res, 405, 'Method not allowed');
240
269
  }
241
270
  catch (error) {
242
- console.error('Unhandled request error:', method, url, error);
271
+ warn(MODULE, `Unhandled request error: ${method} ${url} ${errorMessage(error)}`);
243
272
  if (!res.headersSent) {
244
273
  sendError(res, 500, 'Internal server error');
245
274
  }
246
275
  }
247
276
  });
277
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
248
278
  // ── POST /api/action handler ─────────────────────────────────────────────
249
279
  async function handleAction(req, res) {
250
280
  let body;
@@ -279,7 +309,7 @@ export async function startDashboardServer(options) {
279
309
  sendError(res, 400, err.message);
280
310
  }
281
311
  else {
282
- console.error('Unexpected error during URL validation:', err);
312
+ warn(MODULE, `Unexpected error during URL validation: ${errorMessage(err)}`);
283
313
  sendError(res, 400, 'Invalid URL');
284
314
  }
285
315
  return;
@@ -300,7 +330,7 @@ export async function startDashboardServer(options) {
300
330
  sendError(res, 400, err.message);
301
331
  }
302
332
  else {
303
- console.error('Unexpected error during message validation:', err);
333
+ warn(MODULE, `Unexpected error during message validation: ${errorMessage(err)}`);
304
334
  sendError(res, 400, 'Invalid reason');
305
335
  }
306
336
  return;
@@ -333,8 +363,8 @@ export async function startDashboardServer(options) {
333
363
  stateManager.save();
334
364
  }
335
365
  catch (error) {
336
- console.error('Action failed:', body.action, body.url, error);
337
- sendError(res, 500, `Action failed: ${errorMessage(error)}`);
366
+ warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
367
+ sendError(res, 500, 'Action failed');
338
368
  return;
339
369
  }
340
370
  // Rebuild dashboard data from cached digest + updated state
@@ -349,7 +379,7 @@ export async function startDashboardServer(options) {
349
379
  return;
350
380
  }
351
381
  try {
352
- console.error('Refreshing dashboard data from GitHub...');
382
+ warn(MODULE, 'Refreshing dashboard data from GitHub...');
353
383
  const result = await fetchDashboardData(currentToken);
354
384
  cachedDigest = result.digest;
355
385
  cachedCommentedIssues = result.commentedIssues;
@@ -357,8 +387,8 @@ export async function startDashboardServer(options) {
357
387
  sendJson(res, 200, cachedJsonData);
358
388
  }
359
389
  catch (error) {
360
- console.error('Dashboard refresh failed:', error);
361
- sendError(res, 500, `Refresh failed: ${errorMessage(error)}`);
390
+ warn(MODULE, `Dashboard refresh failed: ${errorMessage(error)}`);
391
+ sendError(res, 500, 'Refresh failed');
362
392
  }
363
393
  }
364
394
  // ── Static file serving ──────────────────────────────────────────────────
@@ -368,8 +398,8 @@ export async function startDashboardServer(options) {
368
398
  try {
369
399
  urlPath = decodeURIComponent(requestUrl.split('?')[0]);
370
400
  }
371
- catch (err) {
372
- console.error('Malformed URL received:', requestUrl, err);
401
+ catch (_err) {
402
+ warn(MODULE, `Malformed URL received: ${requestUrl}`);
373
403
  sendError(res, 400, 'Malformed URL');
374
404
  return;
375
405
  }
@@ -399,7 +429,7 @@ export async function startDashboardServer(options) {
399
429
  filePath = path.join(resolvedAssetsDir, 'index.html');
400
430
  }
401
431
  else {
402
- console.error('Failed to stat file:', filePath, err);
432
+ warn(MODULE, `Failed to stat file: ${filePath}`);
403
433
  sendError(res, 500, 'Internal server error');
404
434
  return;
405
435
  }
@@ -408,9 +438,11 @@ export async function startDashboardServer(options) {
408
438
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
409
439
  try {
410
440
  const content = fs.readFileSync(filePath);
441
+ setSecurityHeaders(res);
411
442
  res.writeHead(200, {
412
443
  'Content-Type': contentType,
413
444
  'Content-Length': content.length,
445
+ 'Cache-Control': 'public, max-age=3600',
414
446
  });
415
447
  res.end(content);
416
448
  }
@@ -420,7 +452,7 @@ export async function startDashboardServer(options) {
420
452
  sendError(res, 404, 'Not found');
421
453
  }
422
454
  else {
423
- console.error('Failed to serve static file:', filePath, error);
455
+ warn(MODULE, `Failed to serve static file: ${filePath}`);
424
456
  sendError(res, 500, 'Failed to read file');
425
457
  }
426
458
  }
@@ -440,18 +472,17 @@ export async function startDashboardServer(options) {
440
472
  catch (err) {
441
473
  const nodeErr = err;
442
474
  if (nodeErr.code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS - 1) {
443
- console.error(`Port ${actualPort} is in use, trying ${actualPort + 1}...`);
475
+ warn(MODULE, `Port ${actualPort} is in use, trying ${actualPort + 1}...`);
444
476
  actualPort++;
445
477
  continue;
446
478
  }
447
- console.error(`Failed to start server: ${nodeErr.message}`);
448
- process.exit(1);
479
+ throw new Error(`Failed to start server: ${nodeErr.message}`, { cause: err });
449
480
  }
450
481
  }
451
482
  // Write PID file so other processes can detect this running server
452
483
  writeDashboardServerInfo({ pid: process.pid, port: actualPort, startedAt: new Date().toISOString() });
453
484
  const serverUrl = `http://localhost:${actualPort}`;
454
- console.error(`Dashboard server running at ${serverUrl}`);
485
+ warn(MODULE, `Dashboard server running at ${serverUrl}`);
455
486
  // ── Background refresh ─────────────────────────────────────────────────
456
487
  // Port is bound and PID file written — now fetch fresh data from GitHub
457
488
  // so subsequent /api/data requests get live data instead of cached state.
@@ -461,10 +492,10 @@ export async function startDashboardServer(options) {
461
492
  cachedDigest = result.digest;
462
493
  cachedCommentedIssues = result.commentedIssues;
463
494
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
464
- console.error('Background data refresh complete');
495
+ warn(MODULE, 'Background data refresh complete');
465
496
  })
466
497
  .catch((error) => {
467
- console.error('Background data refresh failed (serving cached data):', errorMessage(error));
498
+ warn(MODULE, `Background data refresh failed (serving cached data): ${errorMessage(error)}`);
468
499
  });
469
500
  }
470
501
  // ── Open browser ─────────────────────────────────────────────────────────
@@ -473,7 +504,7 @@ export async function startDashboardServer(options) {
473
504
  }
474
505
  // ── Clean shutdown ───────────────────────────────────────────────────────
475
506
  const shutdown = () => {
476
- console.error('\nShutting down dashboard server...');
507
+ warn(MODULE, 'Shutting down dashboard server...');
477
508
  removeDashboardServerInfo();
478
509
  server.close(() => {
479
510
  process.exit(0);
@@ -42,7 +42,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
42
42
  <meta charset="UTF-8">
43
43
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
44
44
  <title>OSS Autopilot - Mission Control</title>
45
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
45
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
46
46
  <link rel="preconnect" href="https://fonts.googleapis.com">
47
47
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
48
48
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
@@ -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
  }
@@ -3,21 +3,6 @@
3
3
  * Extracted from PRMonitor to isolate CI-related logic (#263).
4
4
  */
5
5
  import { CIFailureCategory, ClassifiedCheck, CIStatusResult } from './types.js';
6
- /**
7
- * Known CI check name patterns that indicate fork limitations rather than real failures (#81).
8
- * These are deployment/preview services that require repo-level secrets unavailable in forks.
9
- */
10
- export declare const FORK_LIMITATION_PATTERNS: RegExp[];
11
- /**
12
- * Known CI check name patterns that indicate authorization gates (#81).
13
- * These require maintainer approval and are not real failures.
14
- */
15
- export declare const AUTH_GATE_PATTERNS: RegExp[];
16
- /**
17
- * Known CI check name patterns that indicate infrastructure/transient failures (#145).
18
- * These are runner issues, dependency install problems, or service outages — not code failures.
19
- */
20
- export declare const INFRASTRUCTURE_PATTERNS: RegExp[];
21
6
  /**
22
7
  * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145).
23
8
  * Default is 'actionable' — only known patterns get reclassified.
@@ -45,7 +30,7 @@ export declare function analyzeCheckRuns(checkRuns: Array<{
45
30
  failingCheckConclusions: Map<string, string>;
46
31
  };
47
32
  /** Result shape from analyzeCheckRuns, used by mergeStatuses. */
48
- export interface CheckRunAnalysis {
33
+ interface CheckRunAnalysis {
49
34
  hasFailingChecks: boolean;
50
35
  hasPendingChecks: boolean;
51
36
  hasSuccessfulChecks: boolean;
@@ -53,7 +38,7 @@ export interface CheckRunAnalysis {
53
38
  failingCheckConclusions: Map<string, string>;
54
39
  }
55
40
  /** Result shape from analyzeCombinedStatus, used by mergeStatuses. */
56
- export interface CombinedStatusAnalysis {
41
+ interface CombinedStatusAnalysis {
57
42
  effectiveCombinedState: string;
58
43
  hasStatuses: boolean;
59
44
  failingStatusNames: string[];
@@ -76,3 +61,4 @@ export declare function analyzeCombinedStatus(combinedStatus: {
76
61
  * Priority: failing > pending > passing > unknown.
77
62
  */
78
63
  export declare function mergeStatuses(checkRunAnalysis: CheckRunAnalysis, combinedAnalysis: CombinedStatusAnalysis, checkRunCount: number): CIStatusResult;
64
+ export {};
@@ -6,7 +6,7 @@
6
6
  * Known CI check name patterns that indicate fork limitations rather than real failures (#81).
7
7
  * These are deployment/preview services that require repo-level secrets unavailable in forks.
8
8
  */
9
- export const FORK_LIMITATION_PATTERNS = [
9
+ const FORK_LIMITATION_PATTERNS = [
10
10
  /vercel/i,
11
11
  /netlify/i,
12
12
  /\bpreview\s*deploy/i,
@@ -20,12 +20,12 @@ export const FORK_LIMITATION_PATTERNS = [
20
20
  * Known CI check name patterns that indicate authorization gates (#81).
21
21
  * These require maintainer approval and are not real failures.
22
22
  */
23
- export const AUTH_GATE_PATTERNS = [/authoriz/i, /approval/i, /\bcla\b/i, /license\/cla/i];
23
+ const AUTH_GATE_PATTERNS = [/authoriz/i, /approval/i, /\bcla\b/i, /license\/cla/i];
24
24
  /**
25
25
  * Known CI check name patterns that indicate infrastructure/transient failures (#145).
26
26
  * These are runner issues, dependency install problems, or service outages — not code failures.
27
27
  */
28
- export const INFRASTRUCTURE_PATTERNS = [
28
+ const INFRASTRUCTURE_PATTERNS = [
29
29
  /\binstall\s*(os\s*)?dep(endenc|s\b)/i,
30
30
  /\bsetup\s+fail(ed|ure)?\b/i,
31
31
  /\bservice\s*unavailable/i,