@oss-autopilot/core 0.43.0 → 0.44.0

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.js CHANGED
@@ -45,7 +45,6 @@ const LOCAL_ONLY_COMMANDS = [
45
45
  'version',
46
46
  'setup',
47
47
  'checkSetup',
48
- 'dashboard',
49
48
  'serve',
50
49
  'parse-issue-list',
51
50
  'check-integration',
@@ -537,20 +536,6 @@ dashboardCmd
537
536
  handleCommandError(err);
538
537
  }
539
538
  });
540
- // Keep bare `dashboard` (no subcommand) for backward compat — generates static HTML
541
- dashboardCmd
542
- .option('--open', 'Open in browser')
543
- .option('--json', 'Output as JSON')
544
- .option('--offline', 'Use cached data only (no GitHub API calls)')
545
- .action(async (options) => {
546
- try {
547
- const { runDashboard } = await import('./commands/dashboard.js');
548
- await runDashboard({ open: options.open, json: options.json, offline: options.offline });
549
- }
550
- catch (err) {
551
- handleCommandError(err, options.json);
552
- }
553
- });
554
539
  // Parse issue list command (#82)
555
540
  program
556
541
  .command('parse-issue-list <path>')
@@ -673,8 +658,6 @@ program
673
658
  else {
674
659
  console.log(`OSS Autopilot v${data.version}`);
675
660
  console.log(data.daily?.briefSummary ?? '');
676
- if (data.dashboardPath)
677
- console.log(`Dashboard: ${data.dashboardPath}`);
678
661
  }
679
662
  }
680
663
  }
@@ -3,6 +3,7 @@
3
3
  * Shows or updates configuration
4
4
  */
5
5
  import { getStateManager } from '../core/index.js';
6
+ import { validateGitHubUsername } from './validation.js';
6
7
  export async function runConfig(options) {
7
8
  const stateManager = getStateManager();
8
9
  const currentConfig = stateManager.getState().config;
@@ -17,7 +18,7 @@ export async function runConfig(options) {
17
18
  // Handle specific config keys
18
19
  switch (options.key) {
19
20
  case 'username':
20
- stateManager.updateConfig({ githubUsername: value });
21
+ stateManager.updateConfig({ githubUsername: validateGitHubUsername(value) });
21
22
  break;
22
23
  case 'add-language':
23
24
  if (!currentConfig.languages.includes(value)) {
@@ -7,9 +7,22 @@
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 } from '../core/errors.js';
10
+ import { errorMessage, getHttpStatusCode } from '../core/errors.js';
11
+ import { warn } from '../core/logger.js';
11
12
  import { emptyPRCountsResult } from '../core/github-stats.js';
12
13
  import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
14
+ 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
+ }
13
26
  // Re-export domain functions so existing consumers (tests, dashboard, startup)
14
27
  // can continue importing from './daily.js' without changes.
15
28
  export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, printDigest, CRITICAL_STATUSES, } from '../core/index.js';
@@ -26,35 +39,45 @@ async function fetchPRData(prMonitor, token) {
26
39
  const { prs, failures } = await prMonitor.fetchUserOpenPRs();
27
40
  // Log any failures (but continue with successful checks)
28
41
  if (failures.length > 0) {
29
- console.error(`Warning: ${failures.length} PR fetch(es) failed`);
42
+ warn(MODULE, `${failures.length} PR fetch(es) failed`);
30
43
  }
31
44
  // Fetch merged PR counts, closed PR counts, recently closed PRs, recently merged PRs, and commented issues in parallel
32
45
  // All stats fetches are non-critical (cosmetic/scoring), so isolate their failure
33
46
  const issueMonitor = new IssueConversationMonitor(token);
34
47
  const [mergedResult, closedResult, recentlyClosedPRs, recentlyMergedPRs, issueConversationResult] = await Promise.all([
35
48
  prMonitor.fetchUserMergedPRCounts().catch((err) => {
36
- console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
49
+ if (isRateLimitOrAuthError(err))
50
+ throw err;
51
+ warn(MODULE, `Failed to fetch merged PR counts: ${errorMessage(err)}`);
37
52
  return emptyPRCountsResult();
38
53
  }),
39
54
  prMonitor.fetchUserClosedPRCounts().catch((err) => {
40
- console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
55
+ if (isRateLimitOrAuthError(err))
56
+ throw err;
57
+ warn(MODULE, `Failed to fetch closed PR counts: ${errorMessage(err)}`);
41
58
  return emptyPRCountsResult();
42
59
  }),
43
60
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
44
- console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
61
+ if (isRateLimitOrAuthError(err))
62
+ throw err;
63
+ warn(MODULE, `Failed to fetch recently closed PRs: ${errorMessage(err)}`);
45
64
  return [];
46
65
  }),
47
66
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
48
- console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
67
+ if (isRateLimitOrAuthError(err))
68
+ throw err;
69
+ warn(MODULE, `Failed to fetch recently merged PRs: ${errorMessage(err)}`);
49
70
  return [];
50
71
  }),
51
72
  issueMonitor.fetchCommentedIssues().catch((error) => {
73
+ if (isRateLimitOrAuthError(error))
74
+ throw error;
52
75
  const msg = errorMessage(error);
53
76
  if (msg.includes('No GitHub username configured')) {
54
- console.error(`[DAILY] Issue conversation tracking requires setup: ${msg}`);
77
+ warn(MODULE, `Issue conversation tracking requires setup: ${msg}`);
55
78
  }
56
79
  else {
57
- console.error(`[DAILY] Issue conversation fetch failed: ${msg}`);
80
+ warn(MODULE, `Issue conversation fetch failed: ${msg}`);
58
81
  }
59
82
  return {
60
83
  issues: [],
@@ -64,7 +87,7 @@ async function fetchPRData(prMonitor, token) {
64
87
  ]);
65
88
  const commentedIssues = issueConversationResult.issues;
66
89
  if (issueConversationResult.failures.length > 0) {
67
- console.error(`[DAILY] ${issueConversationResult.failures.length} issue conversation check(s) failed`);
90
+ warn(MODULE, `${issueConversationResult.failures.length} issue conversation check(s) failed`);
68
91
  }
69
92
  const { repos: mergedCounts, monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
70
93
  const { repos: closedCounts, monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed, } = closedResult;
@@ -94,7 +117,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
94
117
  // skip the reset to avoid wiping scores due to transient API failures.
95
118
  const existingReposWithMerges = Object.values(stateManager.getState().repoScores).filter((s) => s.mergedPRCount > 0);
96
119
  if (mergedCounts.size === 0 && existingReposWithMerges.length > 0) {
97
- console.error(`[DAILY] Skipping stale repo reset: API returned 0 merged PR results but state has ${existingReposWithMerges.length} repo(s) with merges. Possible API issue.`);
120
+ warn(MODULE, `Skipping stale repo reset: API returned 0 merged PR results but state has ${existingReposWithMerges.length} repo(s) with merges. Possible API issue.`);
98
121
  }
99
122
  else {
100
123
  for (const score of Object.values(stateManager.getState().repoScores)) {
@@ -111,18 +134,18 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
111
134
  }
112
135
  catch (error) {
113
136
  mergedCountFailures++;
114
- console.error(`[DAILY] Failed to update merged count for ${repo}:`, errorMessage(error));
137
+ warn(MODULE, `Failed to update merged count for ${repo}: ${errorMessage(error)}`);
115
138
  }
116
139
  }
117
140
  if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
118
- console.error(`[DAILY_ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
141
+ warn(MODULE, `[ALL_MERGED_COUNT_UPDATES_FAILED] All ${mergedCounts.size} merged count update(s) failed.`);
119
142
  }
120
143
  // Populate closedWithoutMergeCount in repo scores.
121
144
  // Diagnostic: warn if API returned empty but we have known closed PRs (possible transient API failure).
122
145
  // Unlike merged counts above, there is no stale-reset loop for closed counts, so no skip is needed.
123
146
  const existingReposWithClosed = Object.values(stateManager.getState().repoScores).filter((s) => (s.closedWithoutMergeCount || 0) > 0);
124
147
  if (closedCounts.size === 0 && existingReposWithClosed.length > 0) {
125
- console.error(`[DAILY] Warning: API returned 0 closed PR results but state has ${existingReposWithClosed.length} repo(s) with closed PRs. Possible transient API issue.`);
148
+ warn(MODULE, `API returned 0 closed PR results but state has ${existingReposWithClosed.length} repo(s) with closed PRs. Possible transient API issue.`);
126
149
  }
127
150
  let closedCountFailures = 0;
128
151
  for (const [repo, count] of closedCounts) {
@@ -131,11 +154,11 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
131
154
  }
132
155
  catch (error) {
133
156
  closedCountFailures++;
134
- console.error(`[DAILY] Failed to update closed count for ${repo}:`, errorMessage(error));
157
+ warn(MODULE, `Failed to update closed count for ${repo}: ${errorMessage(error)}`);
135
158
  }
136
159
  }
137
160
  if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
138
- console.error(`[DAILY_ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
161
+ warn(MODULE, `[ALL_CLOSED_COUNT_UPDATES_FAILED] All ${closedCounts.size} closed count update(s) failed.`);
139
162
  }
140
163
  // Update repo signals from observed open PR data (responsiveness, active maintainers).
141
164
  // Only repos with current open PRs get signal updates — repos with no open PRs
@@ -150,11 +173,11 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
150
173
  }
151
174
  catch (error) {
152
175
  signalUpdateFailures++;
153
- console.error(`[DAILY] Failed to update signals for ${repo}:`, errorMessage(error));
176
+ warn(MODULE, `Failed to update signals for ${repo}: ${errorMessage(error)}`);
154
177
  }
155
178
  }
156
179
  if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
157
- console.error(`[DAILY_ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
180
+ warn(MODULE, `[ALL_SIGNAL_UPDATES_FAILED] All ${repoSignals.size} signal update(s) failed. This may indicate corrupted state.`);
158
181
  }
159
182
  // Fetch star counts for all scored repos (used by dashboard minStars filter, #216)
160
183
  const allRepos = Object.keys(stateManager.getState().repoScores);
@@ -163,8 +186,8 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
163
186
  starCounts = await prMonitor.fetchRepoStarCounts(allRepos);
164
187
  }
165
188
  catch (error) {
166
- console.error('[DAILY] Failed to fetch repo star counts:', errorMessage(error));
167
- console.error('[DAILY] Dashboard minStars filter will use cached star counts (or be skipped for repos without cached data).');
189
+ warn(MODULE, `Failed to fetch repo star counts: ${errorMessage(error)}`);
190
+ warn(MODULE, 'Dashboard minStars filter will use cached star counts (or be skipped for repos without cached data).');
168
191
  starCounts = new Map();
169
192
  }
170
193
  let starUpdateFailures = 0;
@@ -174,11 +197,11 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
174
197
  }
175
198
  catch (error) {
176
199
  starUpdateFailures++;
177
- console.error(`[DAILY] Failed to update star count for ${repo}:`, errorMessage(error));
200
+ warn(MODULE, `Failed to update star count for ${repo}: ${errorMessage(error)}`);
178
201
  }
179
202
  }
180
203
  if (starUpdateFailures === starCounts.size && starCounts.size > 0) {
181
- console.error(`[DAILY_ALL_STAR_COUNT_UPDATES_FAILED] All ${starCounts.size} star count update(s) failed.`);
204
+ warn(MODULE, `[ALL_STAR_COUNT_UPDATES_FAILED] All ${starCounts.size} star count update(s) failed.`);
182
205
  }
183
206
  // Auto-sync trustedProjects from repos with merged PRs
184
207
  let trustSyncFailures = 0;
@@ -188,11 +211,11 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
188
211
  }
189
212
  catch (error) {
190
213
  trustSyncFailures++;
191
- console.error(`[DAILY] Failed to sync trusted project ${repo}:`, errorMessage(error));
214
+ warn(MODULE, `Failed to sync trusted project ${repo}: ${errorMessage(error)}`);
192
215
  }
193
216
  }
194
217
  if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
195
- console.error(`[DAILY_ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
218
+ warn(MODULE, `[ALL_TRUST_SYNCS_FAILED] All ${mergedCounts.size} trusted project sync(s) failed. This may indicate corrupted state.`);
196
219
  }
197
220
  }
198
221
  /**
@@ -211,7 +234,7 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
211
234
  }
212
235
  }
213
236
  catch (error) {
214
- console.error('[DAILY] Failed to store monthly merged counts:', errorMessage(error));
237
+ warn(MODULE, `Failed to store monthly merged counts: ${errorMessage(error)}`);
215
238
  }
216
239
  try {
217
240
  if (Object.keys(monthlyClosedCounts).length > 0) {
@@ -219,7 +242,7 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
219
242
  }
220
243
  }
221
244
  catch (error) {
222
- console.error('[DAILY] Failed to store monthly closed counts:', errorMessage(error));
245
+ warn(MODULE, `Failed to store monthly closed counts: ${errorMessage(error)}`);
223
246
  }
224
247
  try {
225
248
  // Build combined monthly opened counts from merged + closed + currently-open PRs
@@ -239,7 +262,7 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
239
262
  }
240
263
  }
241
264
  catch (error) {
242
- console.error('[DAILY] Failed to compute/store monthly opened counts:', errorMessage(error));
265
+ warn(MODULE, `Failed to compute/store monthly opened counts: ${errorMessage(error)}`);
243
266
  }
244
267
  }
245
268
  /**
@@ -254,15 +277,13 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
254
277
  try {
255
278
  const expiredSnoozes = stateManager.expireSnoozes();
256
279
  if (expiredSnoozes.length > 0) {
257
- console.error(`[DAILY] ${expiredSnoozes.length} snoozed PR(s) expired and will resurface:`);
258
- for (const url of expiredSnoozes) {
259
- console.error(` - ${url}`);
260
- }
280
+ const urls = expiredSnoozes.map((url) => ` - ${url}`).join('\n');
281
+ warn(MODULE, `${expiredSnoozes.length} snoozed PR(s) expired and will resurface:\n${urls}`);
261
282
  stateManager.save();
262
283
  }
263
284
  }
264
285
  catch (error) {
265
- console.error('[DAILY] Failed to expire/persist snoozes:', errorMessage(error));
286
+ warn(MODULE, `Failed to expire/persist snoozes: ${errorMessage(error)}`);
266
287
  }
267
288
  // Partition PRs into active vs shelved, auto-unshelving when maintainers engage
268
289
  const shelvedPRs = [];
@@ -322,12 +343,12 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
322
343
  if (isNaN(responseTime) || isNaN(dismissTime)) {
323
344
  // Invalid timestamp — fail open (include issue to be safe) without
324
345
  // permanently removing dismiss record (may be a transient data issue)
325
- console.error(`[DAILY] Invalid timestamp in dismiss check for ${issue.url}, including issue`);
346
+ warn(MODULE, `Invalid timestamp in dismiss check for ${issue.url}, including issue`);
326
347
  return true;
327
348
  }
328
349
  if (responseTime > dismissTime) {
329
350
  // New activity after dismiss — auto-undismiss and resurface
330
- console.error(`[DAILY] Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
351
+ warn(MODULE, `Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
331
352
  stateManager.undismissIssue(issue.url);
332
353
  hasAutoUndismissed = true;
333
354
  return true;
@@ -349,12 +370,12 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
349
370
  if (isNaN(activityTime) || isNaN(dismissTime)) {
350
371
  // Invalid timestamp — fail open (include PR to be safe) without
351
372
  // permanently removing dismiss record (may be a transient data issue)
352
- console.error(`[DAILY] Invalid timestamp in PR dismiss check for ${pr.url}, including PR`);
373
+ warn(MODULE, `Invalid timestamp in PR dismiss check for ${pr.url}, including PR`);
353
374
  return true;
354
375
  }
355
376
  if (activityTime > dismissTime) {
356
377
  // New activity after dismiss — auto-undismiss and resurface
357
- console.error(`[DAILY] Auto-undismissing PR ${pr.url}: new activity at ${pr.updatedAt} after dismiss at ${dismissedAt}`);
378
+ warn(MODULE, `Auto-undismissing PR ${pr.url}: new activity at ${pr.updatedAt} after dismiss at ${dismissedAt}`);
358
379
  stateManager.undismissIssue(pr.url);
359
380
  hasAutoUndismissed = true;
360
381
  return true;
@@ -368,7 +389,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
368
389
  stateManager.save();
369
390
  }
370
391
  catch (error) {
371
- console.error('[DAILY] Failed to persist auto-undismissed state:', errorMessage(error));
392
+ warn(MODULE, `Failed to persist auto-undismissed state: ${errorMessage(error)}`);
372
393
  }
373
394
  }
374
395
  const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls);
@@ -1,22 +1,25 @@
1
1
  /**
2
2
  * Dashboard data fetching and aggregation.
3
3
  * Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
4
- * Separates data concerns from template generation and command orchestration.
4
+ * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
6
  import type { DailyDigest, AgentState, CommentedIssue } from '../core/types.js';
7
+ export interface DashboardStats {
8
+ activePRs: number;
9
+ shelvedPRs: number;
10
+ mergedPRs: number;
11
+ closedPRs: number;
12
+ mergeRate: string;
13
+ }
14
+ export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>): DashboardStats;
7
15
  export interface DashboardFetchResult {
8
16
  digest: DailyDigest;
9
17
  commentedIssues: CommentedIssue[];
10
18
  }
11
- /**
12
- * Fetch fresh dashboard data from GitHub.
13
- * Returns the digest and commented issues, updating state as a side effect.
14
- * Throws if the fetch fails entirely (caller should fall back to cached data).
15
- */
16
19
  export declare function fetchDashboardData(token: string): Promise<DashboardFetchResult>;
17
20
  /**
18
21
  * Compute PRs grouped by repository from a digest and state.
19
- * Used for chart data in both JSON and HTML output.
22
+ * Used for chart data in the dashboard API.
20
23
  */
21
24
  export declare function computePRsByRepo(digest: DailyDigest, state: Readonly<AgentState>): Record<string, {
22
25
  active: number;
@@ -1,17 +1,42 @@
1
1
  /**
2
2
  * Dashboard data fetching and aggregation.
3
3
  * Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
4
- * Separates data concerns from template generation and command orchestration.
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 } from '../core/errors.js';
7
+ import { errorMessage, getHttpStatusCode } from '../core/errors.js';
8
8
  import { emptyPRCountsResult } from '../core/github-stats.js';
9
9
  import { toShelvedPRRef } from './daily.js';
10
+ export function buildDashboardStats(digest, state) {
11
+ const summary = digest.summary || {
12
+ totalActivePRs: 0,
13
+ totalMergedAllTime: 0,
14
+ mergeRate: 0,
15
+ totalNeedingAttention: 0,
16
+ };
17
+ return {
18
+ activePRs: summary.totalActivePRs,
19
+ shelvedPRs: (digest.shelvedPRs || []).length,
20
+ mergedPRs: summary.totalMergedAllTime,
21
+ closedPRs: Object.values(state.repoScores || {}).reduce((sum, s) => sum + (s.closedWithoutMergeCount || 0), 0),
22
+ mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
23
+ };
24
+ }
10
25
  /**
11
26
  * Fetch fresh dashboard data from GitHub.
12
27
  * Returns the digest and commented issues, updating state as a side effect.
13
28
  * Throws if the fetch fails entirely (caller should fall back to cached data).
14
29
  */
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
+ }
15
40
  export async function fetchDashboardData(token) {
16
41
  const stateManager = getStateManager();
17
42
  const prMonitor = new PRMonitor(token);
@@ -19,18 +44,26 @@ export async function fetchDashboardData(token) {
19
44
  const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues] = await Promise.all([
20
45
  prMonitor.fetchUserOpenPRs(),
21
46
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
47
+ if (isRateLimitOrAuthError(err))
48
+ throw err;
22
49
  console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
23
50
  return [];
24
51
  }),
25
52
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
53
+ if (isRateLimitOrAuthError(err))
54
+ throw err;
26
55
  console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
27
56
  return [];
28
57
  }),
29
58
  prMonitor.fetchUserMergedPRCounts().catch((err) => {
59
+ if (isRateLimitOrAuthError(err))
60
+ throw err;
30
61
  console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
31
62
  return emptyPRCountsResult();
32
63
  }),
33
64
  prMonitor.fetchUserClosedPRCounts().catch((err) => {
65
+ if (isRateLimitOrAuthError(err))
66
+ throw err;
34
67
  console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
35
68
  return emptyPRCountsResult();
36
69
  }),
@@ -113,7 +146,7 @@ export async function fetchDashboardData(token) {
113
146
  }
114
147
  /**
115
148
  * Compute PRs grouped by repository from a digest and state.
116
- * Used for chart data in both JSON and HTML output.
149
+ * Used for chart data in the dashboard API.
117
150
  */
118
151
  export function computePRsByRepo(digest, state) {
119
152
  const prsByRepo = {};
@@ -1,14 +1,7 @@
1
1
  /**
2
- * Dashboard data formatting helpers: escapeHtml, DashboardStats, and stats builder.
2
+ * Dashboard HTML formatting helpers.
3
+ * Used by dashboard-templates.ts and dashboard-components.ts for static HTML generation.
3
4
  */
4
- import type { DailyDigest, AgentState } from '../core/types.js';
5
- export interface DashboardStats {
6
- activePRs: number;
7
- shelvedPRs: number;
8
- mergedPRs: number;
9
- closedPRs: number;
10
- mergeRate: string;
11
- }
12
5
  /**
13
6
  * Escape HTML special characters to prevent XSS when interpolating
14
7
  * user-controlled content (e.g. PR titles, comment bodies, author names) into HTML.
@@ -17,4 +10,3 @@ export interface DashboardStats {
17
10
  * the URL scheme if the source is untrusted. GitHub API URLs are trusted.
18
11
  */
19
12
  export declare function escapeHtml(text: string): string;
20
- export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>): DashboardStats;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Dashboard data formatting helpers: escapeHtml, DashboardStats, and stats builder.
2
+ * Dashboard HTML formatting helpers.
3
+ * Used by dashboard-templates.ts and dashboard-components.ts for static HTML generation.
3
4
  */
4
5
  /**
5
6
  * Escape HTML special characters to prevent XSS when interpolating
@@ -16,18 +17,3 @@ export function escapeHtml(text) {
16
17
  .replace(/"/g, '&quot;')
17
18
  .replace(/'/g, '&#39;');
18
19
  }
19
- export function buildDashboardStats(digest, state) {
20
- const summary = digest.summary || {
21
- totalActivePRs: 0,
22
- totalMergedAllTime: 0,
23
- mergeRate: 0,
24
- totalNeedingAttention: 0,
25
- };
26
- return {
27
- activePRs: summary.totalActivePRs,
28
- shelvedPRs: (digest.shelvedPRs || []).length,
29
- mergedPRs: summary.totalMergedAllTime,
30
- closedPRs: Object.values(state.repoScores || {}).reduce((sum, s) => sum + (s.closedWithoutMergeCount || 0), 0),
31
- mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
32
- };
33
- }
@@ -12,7 +12,7 @@ export interface LaunchResult {
12
12
  * Launch the interactive dashboard SPA server as a detached background process.
13
13
  *
14
14
  * Returns the server URL if launched successfully, or null if the SPA assets
15
- * are not available (caller should fall back to static HTML).
15
+ * are not available (dashboard is skipped).
16
16
  *
17
17
  * If a server is already running (detected via PID file + health probe),
18
18
  * returns its URL without launching a new one.
@@ -16,7 +16,7 @@ function sleep(ms) {
16
16
  * Launch the interactive dashboard SPA server as a detached background process.
17
17
  *
18
18
  * Returns the server URL if launched successfully, or null if the SPA assets
19
- * are not available (caller should fall back to static HTML).
19
+ * are not available (dashboard is skipped).
20
20
  *
21
21
  * If a server is already running (detected via PID file + health probe),
22
22
  * returns its URL without launching a new one.
@@ -2,6 +2,6 @@
2
2
  * Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
3
3
  */
4
4
  import type { DailyDigest, AgentState } from '../core/types.js';
5
- import type { DashboardStats } from './dashboard-formatters.js';
5
+ import type { DashboardStats } from './dashboard-data.js';
6
6
  /** Generate the Chart.js JavaScript for the dashboard. */
7
7
  export declare function generateDashboardScripts(stats: DashboardStats, monthlyMerged: Record<string, number>, monthlyClosed: Record<string, number>, monthlyOpened: Record<string, number>, digest: DailyDigest, state: Readonly<AgentState>): string;
@@ -11,8 +11,8 @@ 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
13
  import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN } from './validation.js';
14
- import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
15
- import { buildDashboardStats } from './dashboard-templates.js';
14
+ import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, } from './dashboard-data.js';
15
+ import { openInBrowser } from './startup.js';
16
16
  // ── Constants ────────────────────────────────────────────────────────────────
17
17
  const VALID_ACTIONS = new Set(['shelve', 'unshelve', 'snooze', 'unsnooze']);
18
18
  const MAX_BODY_BYTES = 10_240;
@@ -109,12 +109,11 @@ export async function findRunningDashboardServer() {
109
109
  // ── Helpers ────────────────────────────────────────────────────────────────────
110
110
  /**
111
111
  * Build the JSON payload that the SPA expects from GET /api/data.
112
- * Same shape as the existing `dashboard --json` output.
113
112
  */
114
113
  function buildDashboardJson(digest, state, commentedIssues) {
115
114
  const prsByRepo = computePRsByRepo(digest, state);
116
115
  const topRepos = computeTopRepos(prsByRepo);
117
- const { monthlyMerged } = getMonthlyData(state);
116
+ const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
118
117
  const stats = buildDashboardStats(digest, state);
119
118
  const issueResponses = commentedIssues.filter((i) => i.status === 'new_response');
120
119
  return {
@@ -122,8 +121,13 @@ function buildDashboardJson(digest, state, commentedIssues) {
122
121
  prsByRepo,
123
122
  topRepos: topRepos.map(([repo, data]) => ({ repo, ...data })),
124
123
  monthlyMerged,
124
+ monthlyOpened,
125
+ monthlyClosed,
125
126
  activePRs: digest.openPRs || [],
126
127
  shelvedPRUrls: state.config.shelvedPRUrls || [],
128
+ recentlyMergedPRs: digest.recentlyMergedPRs || [],
129
+ recentlyClosedPRs: digest.recentlyClosedPRs || [],
130
+ autoUnshelvedPRs: digest.autoUnshelvedPRs || [],
127
131
  commentedIssues,
128
132
  issueResponses,
129
133
  };
@@ -181,25 +185,11 @@ export async function startDashboardServer(options) {
181
185
  const stateManager = getStateManager();
182
186
  const resolvedAssetsDir = path.resolve(assetsDir);
183
187
  // ── Cached data ──────────────────────────────────────────────────────────
184
- let cachedDigest;
188
+ // Start immediately with state.json data (written by the daily check that
189
+ // precedes this server launch). A background GitHub fetch refreshes the
190
+ // cache after the port is bound, so the startup poller sees us in time.
191
+ let cachedDigest = stateManager.getState().lastDigest;
185
192
  let cachedCommentedIssues = [];
186
- // Fetch initial data
187
- if (token) {
188
- try {
189
- console.error('Fetching dashboard data from GitHub...');
190
- const result = await fetchDashboardData(token);
191
- cachedDigest = result.digest;
192
- cachedCommentedIssues = result.commentedIssues;
193
- }
194
- catch (error) {
195
- console.error('Failed to fetch data from GitHub:', error);
196
- console.error('Falling back to cached data...');
197
- cachedDigest = stateManager.getState().lastDigest;
198
- }
199
- }
200
- else {
201
- cachedDigest = stateManager.getState().lastDigest;
202
- }
203
193
  if (!cachedDigest) {
204
194
  console.error('No dashboard data available. Run the daily check first:');
205
195
  console.error(' GITHUB_TOKEN=$(gh auth token) npm start -- daily');
@@ -328,9 +318,7 @@ export async function startDashboardServer(options) {
328
318
  return;
329
319
  }
330
320
  // Rebuild dashboard data from cached digest + updated state
331
- if (cachedDigest) {
332
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
333
- }
321
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
334
322
  sendJson(res, 200, cachedJsonData);
335
323
  }
336
324
  // ── POST /api/refresh handler ────────────────────────────────────────────
@@ -444,31 +432,24 @@ export async function startDashboardServer(options) {
444
432
  writeDashboardServerInfo({ pid: process.pid, port: actualPort, startedAt: new Date().toISOString() });
445
433
  const serverUrl = `http://localhost:${actualPort}`;
446
434
  console.error(`Dashboard server running at ${serverUrl}`);
435
+ // ── Background refresh ─────────────────────────────────────────────────
436
+ // Port is bound and PID file written — now fetch fresh data from GitHub
437
+ // so subsequent /api/data requests get live data instead of cached state.
438
+ if (token) {
439
+ fetchDashboardData(token)
440
+ .then((result) => {
441
+ cachedDigest = result.digest;
442
+ cachedCommentedIssues = result.commentedIssues;
443
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
444
+ console.error('Background data refresh complete');
445
+ })
446
+ .catch((error) => {
447
+ console.error('Background data refresh failed (serving cached data):', errorMessage(error));
448
+ });
449
+ }
447
450
  // ── Open browser ─────────────────────────────────────────────────────────
448
451
  if (open) {
449
- const { execFile } = await import('child_process');
450
- let openCmd;
451
- let args;
452
- switch (process.platform) {
453
- case 'darwin':
454
- openCmd = 'open';
455
- args = [serverUrl];
456
- break;
457
- case 'win32':
458
- openCmd = 'cmd';
459
- args = ['/c', 'start', '', serverUrl];
460
- break;
461
- default:
462
- openCmd = 'xdg-open';
463
- args = [serverUrl];
464
- break;
465
- }
466
- execFile(openCmd, args, (error) => {
467
- if (error) {
468
- console.error('Failed to open browser:', error.message);
469
- console.error(`Open manually: ${serverUrl}`);
470
- }
471
- });
452
+ openInBrowser(serverUrl);
472
453
  }
473
454
  // ── Clean shutdown ───────────────────────────────────────────────────────
474
455
  const shutdown = () => {