@oss-autopilot/core 0.42.0 → 0.42.2

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 (50) hide show
  1. package/dist/cli.bundle.cjs +1026 -1018
  2. package/dist/cli.js +18 -30
  3. package/dist/commands/check-integration.js +5 -4
  4. package/dist/commands/comments.js +24 -24
  5. package/dist/commands/daily.d.ts +0 -1
  6. package/dist/commands/daily.js +18 -16
  7. package/dist/commands/dashboard-components.d.ts +33 -0
  8. package/dist/commands/dashboard-components.js +57 -0
  9. package/dist/commands/dashboard-data.js +7 -6
  10. package/dist/commands/dashboard-formatters.d.ts +20 -0
  11. package/dist/commands/dashboard-formatters.js +33 -0
  12. package/dist/commands/dashboard-scripts.d.ts +7 -0
  13. package/dist/commands/dashboard-scripts.js +281 -0
  14. package/dist/commands/dashboard-server.js +3 -2
  15. package/dist/commands/dashboard-styles.d.ts +5 -0
  16. package/dist/commands/dashboard-styles.js +765 -0
  17. package/dist/commands/dashboard-templates.d.ts +6 -18
  18. package/dist/commands/dashboard-templates.js +30 -1134
  19. package/dist/commands/dashboard.js +2 -1
  20. package/dist/commands/dismiss.d.ts +6 -6
  21. package/dist/commands/dismiss.js +13 -13
  22. package/dist/commands/local-repos.js +2 -1
  23. package/dist/commands/parse-list.js +2 -1
  24. package/dist/commands/startup.js +6 -16
  25. package/dist/commands/validation.d.ts +3 -1
  26. package/dist/commands/validation.js +12 -6
  27. package/dist/core/errors.d.ts +9 -0
  28. package/dist/core/errors.js +17 -0
  29. package/dist/core/github-stats.d.ts +14 -21
  30. package/dist/core/github-stats.js +84 -138
  31. package/dist/core/http-cache.d.ts +6 -0
  32. package/dist/core/http-cache.js +16 -4
  33. package/dist/core/index.d.ts +3 -2
  34. package/dist/core/index.js +3 -2
  35. package/dist/core/issue-conversation.js +4 -4
  36. package/dist/core/issue-discovery.d.ts +5 -0
  37. package/dist/core/issue-discovery.js +70 -93
  38. package/dist/core/issue-vetting.js +17 -17
  39. package/dist/core/logger.d.ts +5 -0
  40. package/dist/core/logger.js +8 -0
  41. package/dist/core/pr-monitor.d.ts +6 -20
  42. package/dist/core/pr-monitor.js +16 -52
  43. package/dist/core/review-analysis.js +8 -6
  44. package/dist/core/state.js +4 -5
  45. package/dist/core/test-utils.d.ts +14 -0
  46. package/dist/core/test-utils.js +125 -0
  47. package/dist/core/utils.d.ts +11 -0
  48. package/dist/core/utils.js +21 -0
  49. package/dist/formatters/json.d.ts +0 -1
  50. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * async token fetch to avoid blocking the event loop on `gh auth token`.
11
11
  */
12
12
  import { Command } from 'commander';
13
- import { getGitHubTokenAsync, enableDebug, debug, formatRelativeTime } from './core/index.js';
13
+ import { getGitHubTokenAsync, enableDebug, debug, formatRelativeTime, getCLIVersion } from './core/index.js';
14
+ import { errorMessage } from './core/errors.js';
14
15
  import { outputJson, outputJsonError } from './formatters/json.js';
15
16
  /** Print local repos in human-readable format */
16
17
  function printRepos(repos) {
@@ -23,7 +24,7 @@ function printRepos(repos) {
23
24
  }
24
25
  /** Shared error handler for CLI action catch blocks. */
25
26
  function handleCommandError(err, json) {
26
- const msg = err instanceof Error ? err.message : String(err);
27
+ const msg = errorMessage(err);
27
28
  if (json) {
28
29
  outputJsonError(msg);
29
30
  }
@@ -32,20 +33,7 @@ function handleCommandError(err, json) {
32
33
  }
33
34
  process.exit(1);
34
35
  }
35
- const VERSION = (() => {
36
- try {
37
- // eslint-disable-next-line @typescript-eslint/no-require-imports
38
- const fs = require('fs');
39
- // eslint-disable-next-line @typescript-eslint/no-require-imports
40
- const path = require('path');
41
- const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
42
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
43
- }
44
- catch (_err) {
45
- // package.json may not be readable in all bundle/install configurations — fall back to safe default
46
- return '0.0.0';
47
- }
48
- })();
36
+ const VERSION = getCLIVersion();
49
37
  // Commands that skip the preAction GitHub token check.
50
38
  // startup handles auth internally (returns authError in JSON instead of process.exit).
51
39
  const LOCAL_ONLY_COMMANDS = [
@@ -727,23 +715,23 @@ program
727
715
  });
728
716
  // Dismiss command
729
717
  program
730
- .command('dismiss <issue-url>')
731
- .description('Dismiss issue reply notifications (resurfaces on new activity)')
718
+ .command('dismiss <url>')
719
+ .description('Dismiss notifications for an issue or PR (resurfaces on new activity)')
732
720
  .option('--json', 'Output as JSON')
733
- .action(async (issueUrl, options) => {
721
+ .action(async (url, options) => {
734
722
  try {
735
723
  const { runDismiss } = await import('./commands/dismiss.js');
736
- const data = await runDismiss({ issueUrl });
724
+ const data = await runDismiss({ url });
737
725
  if (options.json) {
738
726
  outputJson(data);
739
727
  }
740
728
  else if (data.dismissed) {
741
- console.log(`Dismissed: ${issueUrl}`);
742
- console.log('Issue reply notifications are now muted.');
729
+ console.log(`Dismissed: ${url}`);
730
+ console.log('Notifications are now muted.');
743
731
  console.log('New responses after this point will resurface automatically.');
744
732
  }
745
733
  else {
746
- console.log('Issue is already dismissed.');
734
+ console.log('Already dismissed.');
747
735
  }
748
736
  }
749
737
  catch (err) {
@@ -752,22 +740,22 @@ program
752
740
  });
753
741
  // Undismiss command
754
742
  program
755
- .command('undismiss <issue-url>')
756
- .description('Undismiss an issue (re-enable reply notifications)')
743
+ .command('undismiss <url>')
744
+ .description('Undismiss an issue or PR (re-enable notifications)')
757
745
  .option('--json', 'Output as JSON')
758
- .action(async (issueUrl, options) => {
746
+ .action(async (url, options) => {
759
747
  try {
760
748
  const { runUndismiss } = await import('./commands/dismiss.js');
761
- const data = await runUndismiss({ issueUrl });
749
+ const data = await runUndismiss({ url });
762
750
  if (options.json) {
763
751
  outputJson(data);
764
752
  }
765
753
  else if (data.undismissed) {
766
- console.log(`Undismissed: ${issueUrl}`);
767
- console.log('Issue reply notifications are active again.');
754
+ console.log(`Undismissed: ${url}`);
755
+ console.log('Notifications are active again.');
768
756
  }
769
757
  else {
770
- console.log('Issue was not dismissed.');
758
+ console.log('Was not dismissed.');
771
759
  }
772
760
  }
773
761
  catch (err) {
@@ -5,6 +5,7 @@
5
5
  import * as path from 'path';
6
6
  import { execFileSync } from 'child_process';
7
7
  import { debug } from '../core/index.js';
8
+ import { errorMessage } from '../core/errors.js';
8
9
  /** File extensions we consider "code" that should be imported/referenced */
9
10
  const CODE_EXTENSIONS = new Set([
10
11
  '.ts',
@@ -76,7 +77,7 @@ export async function runCheckIntegration(options) {
76
77
  newFiles = output ? output.split('\n').filter(Boolean) : [];
77
78
  }
78
79
  catch (error) {
79
- const msg = error instanceof Error ? error.message : String(error);
80
+ const msg = errorMessage(error);
80
81
  throw new Error(`Failed to run git diff: ${msg}`, { cause: error });
81
82
  }
82
83
  // Filter to code files, excluding tests, configs, etc.
@@ -133,9 +134,9 @@ export async function runCheckIntegration(options) {
133
134
  }
134
135
  catch (error) {
135
136
  // git grep exit code 1 = no matches (expected), exit code 2+ = real error
136
- const exitCode = error && typeof error === 'object' && 'status' in error ? error.status : null;
137
- if (exitCode !== null && exitCode !== 1) {
138
- const msg = error instanceof Error ? error.message : String(error);
137
+ const exitCode = error && typeof error === 'object' && 'status' in error ? error.status : undefined;
138
+ if (exitCode !== undefined && exitCode !== 1) {
139
+ const msg = errorMessage(error);
139
140
  debug('check-integration', `git grep failed for "${pattern}": ${msg}`);
140
141
  }
141
142
  }
@@ -18,30 +18,30 @@ export async function runComments(options) {
18
18
  const { owner, repo, number: pull_number } = parsed;
19
19
  // Get PR details
20
20
  const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number });
21
- // Get review comments (inline code comments)
22
- const reviewComments = await paginateAll((page) => octokit.pulls.listReviewComments({
23
- owner,
24
- repo,
25
- pull_number,
26
- per_page: 100,
27
- page,
28
- }));
29
- // Get issue comments (general PR discussion)
30
- const issueComments = await paginateAll((page) => octokit.issues.listComments({
31
- owner,
32
- repo,
33
- issue_number: pull_number,
34
- per_page: 100,
35
- page,
36
- }));
37
- // Get reviews
38
- const reviews = await paginateAll((page) => octokit.pulls.listReviews({
39
- owner,
40
- repo,
41
- pull_number,
42
- per_page: 100,
43
- page,
44
- }));
21
+ // Fetch review comments, issue comments, and reviews in parallel
22
+ const [reviewComments, issueComments, reviews] = await Promise.all([
23
+ paginateAll((page) => octokit.pulls.listReviewComments({
24
+ owner,
25
+ repo,
26
+ pull_number,
27
+ per_page: 100,
28
+ page,
29
+ })),
30
+ paginateAll((page) => octokit.issues.listComments({
31
+ owner,
32
+ repo,
33
+ issue_number: pull_number,
34
+ per_page: 100,
35
+ page,
36
+ })),
37
+ paginateAll((page) => octokit.pulls.listReviews({
38
+ owner,
39
+ repo,
40
+ pull_number,
41
+ per_page: 100,
42
+ page,
43
+ })),
44
+ ]);
45
45
  // Filter out own comments, optionally show bots
46
46
  const username = stateManager.getState().config.githubUsername;
47
47
  const filterComment = (c) => {
@@ -16,7 +16,6 @@ export { computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIs
16
16
  */
17
17
  export interface DailyCheckResult {
18
18
  digest: DailyDigest;
19
- updates: unknown[];
20
19
  capacity: CapacityAssessment;
21
20
  summary: string;
22
21
  briefSummary: string;
@@ -7,6 +7,7 @@
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
11
  import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
11
12
  // Re-export domain functions so existing consumers (tests, dashboard, startup)
12
13
  // can continue importing from './daily.js' without changes.
@@ -33,15 +34,15 @@ async function fetchPRData(prMonitor, token) {
33
34
  prMonitor.fetchUserMergedPRCounts(),
34
35
  prMonitor.fetchUserClosedPRCounts(),
35
36
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
36
- console.error(`Warning: Failed to fetch recently closed PRs: ${err instanceof Error ? err.message : err}`);
37
+ console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
37
38
  return [];
38
39
  }),
39
40
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
40
- console.error(`Warning: Failed to fetch recently merged PRs: ${err instanceof Error ? err.message : err}`);
41
+ console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
41
42
  return [];
42
43
  }),
43
44
  issueMonitor.fetchCommentedIssues().catch((error) => {
44
- const msg = error instanceof Error ? error.message : String(error);
45
+ const msg = errorMessage(error);
45
46
  if (msg.includes('No GitHub username configured')) {
46
47
  console.error(`[DAILY] Issue conversation tracking requires setup: ${msg}`);
47
48
  }
@@ -103,7 +104,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
103
104
  }
104
105
  catch (error) {
105
106
  mergedCountFailures++;
106
- console.error(`[DAILY] Failed to update merged count for ${repo}:`, error instanceof Error ? error.message : error);
107
+ console.error(`[DAILY] Failed to update merged count for ${repo}:`, errorMessage(error));
107
108
  }
108
109
  }
109
110
  if (mergedCountFailures === mergedCounts.size && mergedCounts.size > 0) {
@@ -123,7 +124,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
123
124
  }
124
125
  catch (error) {
125
126
  closedCountFailures++;
126
- console.error(`[DAILY] Failed to update closed count for ${repo}:`, error instanceof Error ? error.message : error);
127
+ console.error(`[DAILY] Failed to update closed count for ${repo}:`, errorMessage(error));
127
128
  }
128
129
  }
129
130
  if (closedCountFailures === closedCounts.size && closedCounts.size > 0) {
@@ -142,7 +143,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
142
143
  }
143
144
  catch (error) {
144
145
  signalUpdateFailures++;
145
- console.error(`[DAILY] Failed to update signals for ${repo}:`, error instanceof Error ? error.message : error);
146
+ console.error(`[DAILY] Failed to update signals for ${repo}:`, errorMessage(error));
146
147
  }
147
148
  }
148
149
  if (signalUpdateFailures === repoSignals.size && repoSignals.size > 0) {
@@ -155,7 +156,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
155
156
  starCounts = await prMonitor.fetchRepoStarCounts(allRepos);
156
157
  }
157
158
  catch (error) {
158
- console.error('[DAILY] Failed to fetch repo star counts:', error instanceof Error ? error.message : error);
159
+ console.error('[DAILY] Failed to fetch repo star counts:', errorMessage(error));
159
160
  console.error('[DAILY] Dashboard minStars filter will use cached star counts (or be skipped for repos without cached data).');
160
161
  starCounts = new Map();
161
162
  }
@@ -166,7 +167,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
166
167
  }
167
168
  catch (error) {
168
169
  starUpdateFailures++;
169
- console.error(`[DAILY] Failed to update star count for ${repo}:`, error instanceof Error ? error.message : error);
170
+ console.error(`[DAILY] Failed to update star count for ${repo}:`, errorMessage(error));
170
171
  }
171
172
  }
172
173
  if (starUpdateFailures === starCounts.size && starCounts.size > 0) {
@@ -180,7 +181,7 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
180
181
  }
181
182
  catch (error) {
182
183
  trustSyncFailures++;
183
- console.error(`[DAILY] Failed to sync trusted project ${repo}:`, error instanceof Error ? error.message : error);
184
+ console.error(`[DAILY] Failed to sync trusted project ${repo}:`, errorMessage(error));
184
185
  }
185
186
  }
186
187
  if (trustSyncFailures === mergedCounts.size && mergedCounts.size > 0) {
@@ -199,13 +200,13 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
199
200
  stateManager.setMonthlyMergedCounts(monthlyCounts);
200
201
  }
201
202
  catch (error) {
202
- console.error('[DAILY] Failed to store monthly merged counts:', error instanceof Error ? error.message : error);
203
+ console.error('[DAILY] Failed to store monthly merged counts:', errorMessage(error));
203
204
  }
204
205
  try {
205
206
  stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
206
207
  }
207
208
  catch (error) {
208
- console.error('[DAILY] Failed to store monthly closed counts:', error instanceof Error ? error.message : error);
209
+ console.error('[DAILY] Failed to store monthly closed counts:', errorMessage(error));
209
210
  }
210
211
  try {
211
212
  // Build combined monthly opened counts from merged + closed + currently-open PRs
@@ -223,7 +224,7 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
223
224
  stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
224
225
  }
225
226
  catch (error) {
226
- console.error('[DAILY] Failed to compute/store monthly opened counts:', error instanceof Error ? error.message : error);
227
+ console.error('[DAILY] Failed to compute/store monthly opened counts:', errorMessage(error));
227
228
  }
228
229
  }
229
230
  /**
@@ -246,7 +247,7 @@ function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
246
247
  }
247
248
  }
248
249
  catch (error) {
249
- console.error('[DAILY] Failed to expire/persist snoozes:', error instanceof Error ? error.message : error);
250
+ console.error('[DAILY] Failed to expire/persist snoozes:', errorMessage(error));
250
251
  }
251
252
  // Partition PRs into active vs shelved, auto-unshelving when maintainers engage
252
253
  const shelvedPRs = [];
@@ -326,14 +327,16 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
326
327
  const issueResponses = filteredCommentedIssues.filter((i) => i.status === 'new_response');
327
328
  const summary = formatSummary(digest, capacity, issueResponses);
328
329
  const snoozedUrls = new Set(Object.keys(stateManager.getState().config.snoozedPRs ?? {}).filter((url) => stateManager.isSnoozed(url)));
329
- const actionableIssues = collectActionableIssues(activePRs, snoozedUrls);
330
+ // Filter dismissed PR URLs from actionable issues (#416)
331
+ const dismissedUrls = new Set(Object.keys(stateManager.getState().config.dismissedIssues ?? {}));
332
+ const nonDismissedPRs = activePRs.filter((pr) => !dismissedUrls.has(pr.url));
333
+ const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls);
330
334
  digest.summary.totalNeedingAttention = actionableIssues.length;
331
335
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
332
336
  const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
333
337
  const repoGroups = groupPRsByRepo(activePRs);
334
338
  return {
335
339
  digest,
336
- updates: [],
337
340
  capacity,
338
341
  summary,
339
342
  briefSummary,
@@ -355,7 +358,6 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
355
358
  function toDailyOutput(result) {
356
359
  return {
357
360
  digest: deduplicateDigest(result.digest),
358
- updates: result.updates,
359
361
  capacity: result.capacity,
360
362
  summary: result.summary,
361
363
  briefSummary: result.briefSummary,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reusable HTML component generators for the dashboard:
3
+ * SVG icons, truncateTitle, renderHealthItems, titleMeta.
4
+ */
5
+ import type { FetchedPR } from '../core/types.js';
6
+ /** SVG path constants for health item icons. */
7
+ export declare const SVG_ICONS: {
8
+ comment: string;
9
+ edit: string;
10
+ xCircle: string;
11
+ conflict: string;
12
+ checklist: string;
13
+ file: string;
14
+ checkCircle: string;
15
+ clock: string;
16
+ lock: string;
17
+ infoCircle: string;
18
+ refresh: string;
19
+ box: string;
20
+ bell: string;
21
+ gitMerge: string;
22
+ };
23
+ export declare function truncateTitle(title: string, max?: number): string;
24
+ /**
25
+ * Render health status items. labelFn output is automatically HTML-escaped.
26
+ * metaFn output is injected raw — callers must ensure metaFn returns safe HTML
27
+ * (use escapeHtml for any user-controlled content within metaFn).
28
+ *
29
+ * Accepts both full FetchedPR objects and lightweight ShelvedPRRef objects.
30
+ */
31
+ export declare function renderHealthItems<T extends Pick<FetchedPR, 'repo' | 'title' | 'url' | 'number'>>(prs: T[], cssClass: string, svgPaths: string, labelFn: string | ((pr: T) => string), metaFn: (pr: T) => string): string;
32
+ /** Default meta: truncated PR title (works for both FetchedPR and ShelvedPRRef). */
33
+ export declare function titleMeta(pr: Pick<FetchedPR, 'title'>): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reusable HTML component generators for the dashboard:
3
+ * SVG icons, truncateTitle, renderHealthItems, titleMeta.
4
+ */
5
+ import { escapeHtml } from './dashboard-formatters.js';
6
+ /** SVG path constants for health item icons. */
7
+ export const SVG_ICONS = {
8
+ comment: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
9
+ edit: '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
10
+ xCircle: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
11
+ conflict: '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
12
+ checklist: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
13
+ file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/>',
14
+ checkCircle: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
15
+ clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
16
+ lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
17
+ infoCircle: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>',
18
+ refresh: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>',
19
+ box: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
20
+ bell: '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
21
+ gitMerge: '<circle cx="7" cy="18" r="3"/><circle cx="7" cy="6" r="3"/><circle cx="17" cy="12" r="3"/><line x1="7" y1="9" x2="7" y2="15"/><path d="M7 9c0 4 10 3 10 3"/>',
22
+ };
23
+ export function truncateTitle(title, max = 50) {
24
+ const truncated = title.length <= max ? title : title.slice(0, max) + '...';
25
+ return escapeHtml(truncated);
26
+ }
27
+ /**
28
+ * Render health status items. labelFn output is automatically HTML-escaped.
29
+ * metaFn output is injected raw — callers must ensure metaFn returns safe HTML
30
+ * (use escapeHtml for any user-controlled content within metaFn).
31
+ *
32
+ * Accepts both full FetchedPR objects and lightweight ShelvedPRRef objects.
33
+ */
34
+ export function renderHealthItems(prs, cssClass, svgPaths, labelFn, metaFn) {
35
+ return prs
36
+ .map((pr) => {
37
+ const rawLabel = typeof labelFn === 'string' ? labelFn : labelFn(pr);
38
+ const label = escapeHtml(rawLabel);
39
+ return `
40
+ <div class="health-item ${cssClass}" data-status="${cssClass}" data-repo="${escapeHtml(pr.repo)}" data-title="${escapeHtml(pr.title.toLowerCase())}">
41
+ <div class="health-icon">
42
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
43
+ ${svgPaths}
44
+ </svg>
45
+ </div>
46
+ <div class="health-content">
47
+ <div class="health-title"><a href="${escapeHtml(pr.url)}" target="_blank">${escapeHtml(pr.repo)}#${pr.number}</a> - ${label}</div>
48
+ <div class="health-meta">${metaFn(pr)}</div>
49
+ </div>
50
+ </div>`;
51
+ })
52
+ .join('');
53
+ }
54
+ /** Default meta: truncated PR title (works for both FetchedPR and ShelvedPRRef). */
55
+ export function titleMeta(pr) {
56
+ return truncateTitle(pr.title);
57
+ }
@@ -4,6 +4,7 @@
4
4
  * Separates data concerns from template generation and command orchestration.
5
5
  */
6
6
  import { getStateManager, PRMonitor, IssueConversationMonitor } from '../core/index.js';
7
+ import { errorMessage } from '../core/errors.js';
7
8
  import { toShelvedPRRef } from './daily.js';
8
9
  /**
9
10
  * Fetch fresh dashboard data from GitHub.
@@ -17,17 +18,17 @@ export async function fetchDashboardData(token) {
17
18
  const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues] = await Promise.all([
18
19
  prMonitor.fetchUserOpenPRs(),
19
20
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
20
- console.error(`Warning: Failed to fetch recently closed PRs: ${err instanceof Error ? err.message : err}`);
21
+ console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
21
22
  return [];
22
23
  }),
23
24
  prMonitor.fetchRecentlyMergedPRs().catch((err) => {
24
- console.error(`Warning: Failed to fetch recently merged PRs: ${err instanceof Error ? err.message : err}`);
25
+ console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
25
26
  return [];
26
27
  }),
27
28
  prMonitor.fetchUserMergedPRCounts(),
28
29
  prMonitor.fetchUserClosedPRCounts(),
29
30
  issueMonitor.fetchCommentedIssues().catch((error) => {
30
- const msg = error instanceof Error ? error.message : String(error);
31
+ const msg = errorMessage(error);
31
32
  if (msg.includes('No GitHub username configured')) {
32
33
  console.error(`[DASHBOARD] Issue conversation tracking requires setup: ${msg}`);
33
34
  }
@@ -54,13 +55,13 @@ export async function fetchDashboardData(token) {
54
55
  stateManager.setMonthlyMergedCounts(monthlyCounts);
55
56
  }
56
57
  catch (error) {
57
- console.error('[DASHBOARD] Failed to store monthly merged counts:', error instanceof Error ? error.message : error);
58
+ console.error('[DASHBOARD] Failed to store monthly merged counts:', errorMessage(error));
58
59
  }
59
60
  try {
60
61
  stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
61
62
  }
62
63
  catch (error) {
63
- console.error('[DASHBOARD] Failed to store monthly closed counts:', error instanceof Error ? error.message : error);
64
+ console.error('[DASHBOARD] Failed to store monthly closed counts:', errorMessage(error));
64
65
  }
65
66
  try {
66
67
  const combinedOpenedCounts = { ...openedFromMerged };
@@ -76,7 +77,7 @@ export async function fetchDashboardData(token) {
76
77
  stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
77
78
  }
78
79
  catch (error) {
79
- console.error('[DASHBOARD] Failed to store monthly opened counts:', error instanceof Error ? error.message : error);
80
+ console.error('[DASHBOARD] Failed to store monthly opened counts:', errorMessage(error));
80
81
  }
81
82
  const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
82
83
  // Apply shelve partitioning for display (auto-unshelve only runs in daily check)
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Dashboard data formatting helpers: escapeHtml, DashboardStats, and stats builder.
3
+ */
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
+ /**
13
+ * Escape HTML special characters to prevent XSS when interpolating
14
+ * user-controlled content (e.g. PR titles, comment bodies, author names) into HTML.
15
+ * Note: This escapes HTML entity characters only. It does not sanitize URL schemes
16
+ * (e.g., javascript:) — callers placing values in href attributes should validate
17
+ * the URL scheme if the source is untrusted. GitHub API URLs are trusted.
18
+ */
19
+ export declare function escapeHtml(text: string): string;
20
+ export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>): DashboardStats;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Dashboard data formatting helpers: escapeHtml, DashboardStats, and stats builder.
3
+ */
4
+ /**
5
+ * Escape HTML special characters to prevent XSS when interpolating
6
+ * user-controlled content (e.g. PR titles, comment bodies, author names) into HTML.
7
+ * Note: This escapes HTML entity characters only. It does not sanitize URL schemes
8
+ * (e.g., javascript:) — callers placing values in href attributes should validate
9
+ * the URL scheme if the source is untrusted. GitHub API URLs are trusted.
10
+ */
11
+ export function escapeHtml(text) {
12
+ return text
13
+ .replace(/&/g, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#39;');
18
+ }
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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
3
+ */
4
+ import type { DailyDigest, AgentState } from '../core/types.js';
5
+ import type { DashboardStats } from './dashboard-formatters.js';
6
+ /** Generate the Chart.js JavaScript for the dashboard. */
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;