@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
@@ -1,38 +1,16 @@
1
1
  /**
2
- * Dashboard HTML template generation.
3
- * Contains all HTML/CSS/JS template strings, escapeHtml(), and rendering helpers.
2
+ * Dashboard HTML template assembly.
3
+ * Imports styles, components, formatters, and scripts from focused sub-modules,
4
+ * then weaves them into the final HTML document.
5
+ *
4
6
  * Pure functions with no side effects — all data is passed in as arguments.
5
7
  */
6
- /**
7
- * Escape HTML special characters to prevent XSS when interpolating
8
- * user-controlled content (e.g. PR titles, comment bodies, author names) into HTML.
9
- * Note: This escapes HTML entity characters only. It does not sanitize URL schemes
10
- * (e.g., javascript:) callers placing values in href attributes should validate
11
- * the URL scheme if the source is untrusted. GitHub API URLs are trusted.
12
- */
13
- export function escapeHtml(text) {
14
- return text
15
- .replace(/&/g, '&')
16
- .replace(/</g, '&lt;')
17
- .replace(/>/g, '&gt;')
18
- .replace(/"/g, '&quot;')
19
- .replace(/'/g, '&#39;');
20
- }
21
- export function buildDashboardStats(digest, state) {
22
- const summary = digest.summary || {
23
- totalActivePRs: 0,
24
- totalMergedAllTime: 0,
25
- mergeRate: 0,
26
- totalNeedingAttention: 0,
27
- };
28
- return {
29
- activePRs: summary.totalActivePRs,
30
- shelvedPRs: (digest.shelvedPRs || []).length,
31
- mergedPRs: summary.totalMergedAllTime,
32
- closedPRs: Object.values(state.repoScores || {}).reduce((sum, s) => sum + (s.closedWithoutMergeCount || 0), 0),
33
- mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
34
- };
35
- }
8
+ import { escapeHtml } from './dashboard-formatters.js';
9
+ import { DASHBOARD_CSS } from './dashboard-styles.js';
10
+ import { SVG_ICONS, truncateTitle, renderHealthItems, titleMeta } from './dashboard-components.js';
11
+ import { generateDashboardScripts } from './dashboard-scripts.js';
12
+ // Re-export public API so existing consumers don't break
13
+ export { escapeHtml, buildDashboardStats } from './dashboard-formatters.js';
36
14
  export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state, issueResponses = []) {
37
15
  const approachingDormantDays = state.config?.approachingDormantDays ?? 25;
38
16
  const shelvedPRs = digest.shelvedPRs || [];
@@ -57,56 +35,6 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
57
35
  ...(digest.ciBlockedPRs || []),
58
36
  ...(digest.ciNotRunningPRs || []),
59
37
  ];
60
- function truncateTitle(title, max = 50) {
61
- const truncated = title.length <= max ? title : title.slice(0, max) + '...';
62
- return escapeHtml(truncated);
63
- }
64
- /**
65
- * Render health status items. labelFn output is automatically HTML-escaped.
66
- * metaFn output is injected raw — callers must ensure metaFn returns safe HTML
67
- * (use escapeHtml for any user-controlled content within metaFn).
68
- *
69
- * Accepts both full FetchedPR objects and lightweight ShelvedPRRef objects.
70
- */
71
- function renderHealthItems(prs, cssClass, svgPaths, labelFn, metaFn) {
72
- return prs
73
- .map((pr) => {
74
- const rawLabel = typeof labelFn === 'string' ? labelFn : labelFn(pr);
75
- const label = escapeHtml(rawLabel);
76
- return `
77
- <div class="health-item ${cssClass}" data-status="${cssClass}" data-repo="${escapeHtml(pr.repo)}" data-title="${escapeHtml(pr.title.toLowerCase())}">
78
- <div class="health-icon">
79
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
80
- ${svgPaths}
81
- </svg>
82
- </div>
83
- <div class="health-content">
84
- <div class="health-title"><a href="${escapeHtml(pr.url)}" target="_blank">${escapeHtml(pr.repo)}#${pr.number}</a> - ${label}</div>
85
- <div class="health-meta">${metaFn(pr)}</div>
86
- </div>
87
- </div>`;
88
- })
89
- .join('');
90
- }
91
- // SVG path constants for health item icons
92
- const SVG = {
93
- 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"/>',
94
- 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"/>',
95
- 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"/>',
96
- 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"/>',
97
- 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"/>',
98
- 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"/>',
99
- checkCircle: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
100
- clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
101
- lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
102
- 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"/>',
103
- refresh: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>',
104
- 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"/>',
105
- 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"/>',
106
- 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"/>',
107
- };
108
- // Default meta: truncated PR title (works for both FetchedPR and ShelvedPRRef)
109
- const titleMeta = (pr) => truncateTitle(pr.title);
110
38
  return `<!DOCTYPE html>
111
39
  <html lang="en">
112
40
  <head>
@@ -117,766 +45,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
117
45
  <link rel="preconnect" href="https://fonts.googleapis.com">
118
46
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
119
47
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
120
- <style>
121
- :root, [data-theme="dark"] {
122
- --bg-base: #080b10;
123
- --bg-surface: rgba(22, 27, 34, 0.65);
124
- --bg-elevated: rgba(28, 33, 40, 0.8);
125
- --border: rgba(48, 54, 61, 0.6);
126
- --border-muted: rgba(33, 38, 45, 0.5);
127
- --text-primary: #e6edf3;
128
- --text-secondary: #8b949e;
129
- --text-muted: #6e7681;
130
- --accent-merged: #a855f7;
131
- --accent-merged-dim: rgba(168, 85, 247, 0.12);
132
- --accent-open: #3fb950;
133
- --accent-open-dim: rgba(63, 185, 80, 0.12);
134
- --accent-warning: #d29922;
135
- --accent-warning-dim: rgba(210, 153, 34, 0.12);
136
- --accent-error: #f85149;
137
- --accent-error-dim: rgba(248, 81, 73, 0.10);
138
- --accent-conflict: #da3633;
139
- --accent-info: #58a6ff;
140
- --accent-info-dim: rgba(88, 166, 255, 0.08);
141
- --chart-border: rgba(8, 11, 16, 0.8);
142
- --chart-grid: rgba(48, 54, 61, 0.3);
143
- --scrollbar-track: rgba(28, 33, 40, 0.8);
144
- --scrollbar-thumb: rgba(48, 54, 61, 0.6);
145
- }
146
-
147
- [data-theme="light"] {
148
- --bg-base: #f6f8fa;
149
- --bg-surface: rgba(255, 255, 255, 0.85);
150
- --bg-elevated: rgba(246, 248, 250, 0.95);
151
- --border: rgba(208, 215, 222, 0.6);
152
- --border-muted: rgba(216, 222, 228, 0.5);
153
- --text-primary: #1f2328;
154
- --text-secondary: #656d76;
155
- --text-muted: #8b949e;
156
- --accent-merged: #8250df;
157
- --accent-merged-dim: rgba(130, 80, 223, 0.1);
158
- --accent-open: #1a7f37;
159
- --accent-open-dim: rgba(26, 127, 55, 0.1);
160
- --accent-warning: #9a6700;
161
- --accent-warning-dim: rgba(154, 103, 0, 0.1);
162
- --accent-error: #cf222e;
163
- --accent-error-dim: rgba(207, 34, 46, 0.08);
164
- --accent-conflict: #cf222e;
165
- --accent-info: #0969da;
166
- --accent-info-dim: rgba(9, 105, 218, 0.08);
167
- --chart-border: rgba(255, 255, 255, 0.8);
168
- --chart-grid: rgba(208, 215, 222, 0.4);
169
- --scrollbar-track: rgba(246, 248, 250, 0.95);
170
- --scrollbar-thumb: rgba(208, 215, 222, 0.6);
171
- }
172
-
173
- @media (prefers-color-scheme: light) {
174
- :root:not([data-theme="dark"]) {
175
- --bg-base: #f6f8fa;
176
- --bg-surface: rgba(255, 255, 255, 0.85);
177
- --bg-elevated: rgba(246, 248, 250, 0.95);
178
- --border: rgba(208, 215, 222, 0.6);
179
- --border-muted: rgba(216, 222, 228, 0.5);
180
- --text-primary: #1f2328;
181
- --text-secondary: #656d76;
182
- --text-muted: #8b949e;
183
- --accent-merged: #8250df;
184
- --accent-merged-dim: rgba(130, 80, 223, 0.1);
185
- --accent-open: #1a7f37;
186
- --accent-open-dim: rgba(26, 127, 55, 0.1);
187
- --accent-warning: #9a6700;
188
- --accent-warning-dim: rgba(154, 103, 0, 0.1);
189
- --accent-error: #cf222e;
190
- --accent-error-dim: rgba(207, 34, 46, 0.08);
191
- --accent-conflict: #cf222e;
192
- --accent-info: #0969da;
193
- --accent-info-dim: rgba(9, 105, 218, 0.08);
194
- --chart-border: rgba(255, 255, 255, 0.8);
195
- --chart-grid: rgba(208, 215, 222, 0.4);
196
- --scrollbar-track: rgba(246, 248, 250, 0.95);
197
- --scrollbar-thumb: rgba(208, 215, 222, 0.6);
198
- }
199
- }
200
-
201
- * { margin: 0; padding: 0; box-sizing: border-box; }
202
-
203
- body {
204
- font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
205
- background: var(--bg-base);
206
- color: var(--text-primary);
207
- min-height: 100vh;
208
- line-height: 1.5;
209
- overflow-x: hidden;
210
- }
211
-
212
- body::before {
213
- content: '';
214
- position: fixed;
215
- top: -20%; left: -10%;
216
- width: 60%; height: 60%;
217
- background: radial-gradient(ellipse, rgba(88, 166, 255, 0.06) 0%, transparent 70%);
218
- pointer-events: none;
219
- z-index: 0;
220
- }
221
-
222
- body::after {
223
- content: '';
224
- position: fixed;
225
- bottom: -20%; right: -10%;
226
- width: 50%; height: 50%;
227
- background: radial-gradient(ellipse, rgba(168, 85, 247, 0.05) 0%, transparent 70%);
228
- pointer-events: none;
229
- z-index: 0;
230
- }
231
-
232
- [data-theme="light"] body::before,
233
- [data-theme="light"] body::after {
234
- display: none;
235
- }
236
-
237
- .container {
238
- max-width: 1400px;
239
- margin: 0 auto;
240
- padding: 2rem;
241
- position: relative;
242
- z-index: 1;
243
- }
244
-
245
- .header {
246
- display: flex;
247
- align-items: center;
248
- justify-content: space-between;
249
- margin-bottom: 1.5rem;
250
- padding-bottom: 1rem;
251
- border-bottom: 1px solid var(--border-muted);
252
- }
253
-
254
- .header-left {
255
- display: flex;
256
- align-items: center;
257
- gap: 1rem;
258
- }
259
-
260
- .logo {
261
- width: 44px;
262
- height: 44px;
263
- background: linear-gradient(135deg, var(--accent-info) 0%, var(--accent-merged) 50%, #f778ba 100%);
264
- border-radius: 12px;
265
- display: flex;
266
- align-items: center;
267
- justify-content: center;
268
- font-size: 1.5rem;
269
- box-shadow: 0 0 24px rgba(168, 85, 247, 0.3), 0 0 48px rgba(88, 166, 255, 0.15);
270
- }
271
-
272
- .header h1 {
273
- font-size: 1.75rem;
274
- font-weight: 600;
275
- letter-spacing: -0.02em;
276
- background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
277
- -webkit-background-clip: text;
278
- -webkit-text-fill-color: transparent;
279
- background-clip: text;
280
- }
281
-
282
- .header-subtitle {
283
- font-family: 'Geist Mono', monospace;
284
- font-size: 0.75rem;
285
- color: var(--text-muted);
286
- text-transform: uppercase;
287
- letter-spacing: 0.1em;
288
- }
289
-
290
- .timestamp {
291
- font-family: 'Geist Mono', monospace;
292
- font-size: 0.8rem;
293
- color: var(--text-muted);
294
- display: flex;
295
- align-items: center;
296
- gap: 0.5rem;
297
- }
298
-
299
- .timestamp::before {
300
- content: '';
301
- width: 8px;
302
- height: 8px;
303
- background: var(--accent-open);
304
- border-radius: 50%;
305
- animation: pulse 2s ease-in-out infinite;
306
- }
307
-
308
- @keyframes pulse {
309
- 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(35, 134, 54, 0.4); }
310
- 50% { opacity: 0.8; box-shadow: 0 0 0 8px rgba(35, 134, 54, 0); }
311
- }
312
-
313
- .stats-grid {
314
- display: flex;
315
- background: var(--bg-surface);
316
- border: 1px solid var(--border-muted);
317
- border-radius: 12px;
318
- margin-bottom: 1.5rem;
319
- overflow: hidden;
320
- }
321
-
322
- @media (max-width: 768px) {
323
- .stats-grid { flex-wrap: wrap; }
324
- .stat-card { flex: 1 1 33%; }
325
- }
326
-
327
- .stat-card {
328
- flex: 1;
329
- padding: 1rem 1.25rem;
330
- position: relative;
331
- transition: background 0.2s ease;
332
- }
333
-
334
- .stat-card + .stat-card {
335
- border-left: 1px solid var(--border-muted);
336
- }
337
-
338
- .stat-card:hover {
339
- background: rgba(255, 255, 255, 0.02);
340
- }
341
-
342
- .stat-card::after {
343
- content: '';
344
- position: absolute;
345
- bottom: 0; left: 0.75rem; right: 0.75rem;
346
- height: 2px;
347
- background: var(--accent-color, var(--border));
348
- border-radius: 2px;
349
- opacity: 0.7;
350
- }
351
-
352
- .stat-card.active { --accent-color: var(--accent-open); }
353
- .stat-card.merged { --accent-color: var(--accent-merged); }
354
- .stat-card.closed { --accent-color: var(--text-muted); }
355
- .stat-card.rate { --accent-color: var(--accent-info); }
356
-
357
- .stat-value {
358
- font-family: 'Geist Mono', monospace;
359
- font-size: 1.75rem;
360
- font-weight: 600;
361
- line-height: 1;
362
- margin-bottom: 0.25rem;
363
- }
364
-
365
- .stat-card.active .stat-value { color: var(--accent-open); }
366
- .stat-card.merged .stat-value { color: var(--accent-merged); }
367
- .stat-card.closed .stat-value { color: var(--text-muted); }
368
- .stat-card.rate .stat-value { color: var(--accent-info); }
369
-
370
- .stat-label {
371
- font-size: 0.7rem;
372
- color: var(--text-secondary);
373
- text-transform: uppercase;
374
- letter-spacing: 0.05em;
375
- }
376
-
377
- .health-section {
378
- background: var(--bg-surface);
379
- border: 1px solid var(--border-muted);
380
- border-radius: 10px;
381
- padding: 1.25rem;
382
- margin-bottom: 1.25rem;
383
- }
384
-
385
- .health-header {
386
- display: flex;
387
- align-items: center;
388
- gap: 0.75rem;
389
- margin-bottom: 1rem;
390
- }
391
-
392
- .health-header h2 {
393
- font-size: 1rem;
394
- font-weight: 600;
395
- color: var(--text-primary);
396
- }
397
-
398
- .health-badge {
399
- font-family: 'Geist Mono', monospace;
400
- font-size: 0.7rem;
401
- padding: 0.25rem 0.5rem;
402
- border-radius: 4px;
403
- background: var(--accent-error-dim);
404
- color: var(--accent-error);
405
- }
406
-
407
- .health-items {
408
- display: grid;
409
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
410
- gap: 0.75rem;
411
- }
412
-
413
- .health-item {
414
- display: flex;
415
- align-items: center;
416
- gap: 0.75rem;
417
- padding: 0.75rem 1rem;
418
- background: var(--bg-elevated);
419
- border-radius: 8px;
420
- border-left: 3px solid;
421
- transition: transform 0.15s ease;
422
- }
423
-
424
- .health-item:hover { transform: translateX(4px); }
425
-
426
- .health-item.ci-failing {
427
- border-left-color: var(--accent-error);
428
- background: var(--accent-error-dim);
429
- }
430
-
431
- .health-item.conflict {
432
- border-left-color: var(--accent-conflict);
433
- background: rgba(218, 54, 51, 0.1);
434
- }
435
-
436
- .health-item.incomplete-checklist {
437
- border-left-color: var(--accent-info);
438
- }
439
- .health-item.needs-response,
440
- .health-item.needs-changes {
441
- border-left-color: var(--accent-warning);
442
- background: var(--accent-warning-dim);
443
- }
444
-
445
- .health-item.changes-addressed,
446
- .health-item.waiting-maintainer {
447
- border-left-color: var(--accent-info);
448
- background: var(--accent-info-dim);
449
- }
450
-
451
- .health-item.ci-not-running {
452
- border-left-color: var(--text-muted);
453
- background: rgba(110, 118, 129, 0.1);
454
- }
455
-
456
- .health-item.missing-files {
457
- border-left-color: var(--accent-warning);
458
- background: var(--accent-warning-dim);
459
- }
460
-
461
- .health-item.ci-blocked {
462
- border-left-color: var(--text-muted);
463
- background: rgba(110, 118, 129, 0.1);
464
- }
465
-
466
- .health-item.needs-rebase {
467
- border-left-color: var(--accent-warning);
468
- background: var(--accent-warning-dim);
469
- }
470
-
471
- .health-item.shelved {
472
- border-left-color: var(--text-muted);
473
- background: rgba(110, 118, 129, 0.06);
474
- opacity: 0.6;
475
- }
476
-
477
- .health-item.shelved .health-icon { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); }
478
-
479
- .health-item.auto-unshelved {
480
- border-left-color: var(--accent-info);
481
- background: var(--accent-info-dim);
482
- }
483
-
484
- .health-item.auto-unshelved .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }
485
-
486
- .stat-card.shelved { --accent-color: var(--text-muted); }
487
- .stat-card.shelved .stat-value { color: var(--text-muted); }
488
-
489
- .waiting-section {
490
- border-color: rgba(88, 166, 255, 0.2);
491
- }
492
-
493
- .health-icon {
494
- width: 32px;
495
- height: 32px;
496
- border-radius: 8px;
497
- display: flex;
498
- align-items: center;
499
- justify-content: center;
500
- font-size: 1rem;
501
- flex-shrink: 0;
502
- }
503
-
504
- .health-item.ci-failing .health-icon { background: var(--accent-error-dim); color: var(--accent-error); }
505
- .health-item.conflict .health-icon { background: rgba(218, 54, 51, 0.15); color: var(--accent-conflict); }
506
- .health-item.incomplete-checklist .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }
507
- .health-item.needs-response .health-icon,
508
- .health-item.needs-changes .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }
509
- .health-item.changes-addressed .health-icon,
510
- .health-item.waiting-maintainer .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }
511
- .health-item.ci-not-running .health-icon { background: rgba(110, 118, 129, 0.15); color: var(--text-muted); }
512
- .health-item.missing-files .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }
513
- .health-item.ci-blocked .health-icon { background: rgba(110, 118, 129, 0.15); color: var(--text-muted); }
514
- .health-item.needs-rebase .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }
515
-
516
- .health-content { flex: 1; min-width: 0; }
517
-
518
- .health-title {
519
- font-size: 0.85rem;
520
- font-weight: 500;
521
- color: var(--text-primary);
522
- white-space: nowrap;
523
- overflow: hidden;
524
- text-overflow: ellipsis;
525
- }
526
-
527
- .health-title a { color: inherit; text-decoration: none; }
528
- .health-title a:hover { color: var(--accent-info); }
529
-
530
- .health-meta {
531
- font-family: 'Geist Mono', monospace;
532
- font-size: 0.7rem;
533
- color: var(--text-muted);
534
- }
535
-
536
- .health-empty {
537
- display: flex;
538
- align-items: center;
539
- justify-content: center;
540
- padding: 2rem;
541
- color: var(--text-muted);
542
- font-size: 0.9rem;
543
- }
544
-
545
- .health-empty::before {
546
- content: '\\2713';
547
- display: inline-flex;
548
- align-items: center;
549
- justify-content: center;
550
- width: 24px;
551
- height: 24px;
552
- background: var(--accent-open-dim);
553
- color: var(--accent-open);
554
- border-radius: 50%;
555
- margin-right: 0.75rem;
556
- font-weight: bold;
557
- }
558
-
559
- .main-grid {
560
- display: grid;
561
- grid-template-columns: 1fr 1fr;
562
- gap: 1.25rem;
563
- margin-bottom: 1.25rem;
564
- }
565
-
566
- @media (max-width: 1024px) { .main-grid { grid-template-columns: 1fr; } }
567
-
568
- .card {
569
- background: var(--bg-surface);
570
- border: 1px solid var(--border-muted);
571
- border-radius: 10px;
572
- overflow: hidden;
573
- }
574
-
575
- .card-header {
576
- display: flex;
577
- align-items: center;
578
- justify-content: space-between;
579
- padding: 0.75rem 1.125rem;
580
- border-bottom: 1px solid var(--border-muted);
581
- }
582
-
583
- .card-title {
584
- font-size: 0.75rem;
585
- font-weight: 600;
586
- color: var(--text-secondary);
587
- text-transform: uppercase;
588
- letter-spacing: 0.04em;
589
- }
590
-
591
- .card-body { padding: 1rem 1.125rem; }
592
-
593
- .chart-container {
594
- position: relative;
595
- height: 260px;
596
- }
597
-
598
- .pr-list-section {
599
- background: var(--bg-surface);
600
- border: 1px solid var(--border-muted);
601
- border-radius: 10px;
602
- overflow: hidden;
603
- }
604
-
605
- .pr-list-header {
606
- display: flex;
607
- align-items: center;
608
- justify-content: space-between;
609
- padding: 0.75rem 1.125rem;
610
- border-bottom: 1px solid var(--border-muted);
611
- }
612
-
613
- .pr-list-title {
614
- font-size: 0.75rem;
615
- font-weight: 600;
616
- color: var(--text-secondary);
617
- text-transform: uppercase;
618
- letter-spacing: 0.04em;
619
- }
620
-
621
- .pr-count {
622
- font-family: 'Geist Mono', monospace;
623
- font-size: 0.75rem;
624
- padding: 0.25rem 0.5rem;
625
- background: var(--accent-open-dim);
626
- color: var(--accent-open);
627
- border-radius: 4px;
628
- }
629
-
630
- .pr-list {
631
- max-height: 600px;
632
- overflow-y: auto;
633
- }
634
-
635
- .pr-list::-webkit-scrollbar { width: 6px; }
636
- .pr-list::-webkit-scrollbar-track { background: var(--scrollbar-track, var(--bg-elevated)); }
637
- .pr-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb, var(--border)); border-radius: 3px; }
638
-
639
- .pr-item {
640
- display: flex;
641
- align-items: flex-start;
642
- gap: 1rem;
643
- padding: 1rem 1.25rem;
644
- border-bottom: 1px solid var(--border-muted);
645
- transition: background 0.15s ease;
646
- }
647
-
648
- .pr-item:last-child { border-bottom: none; }
649
- .pr-item:hover { background: var(--bg-elevated); }
650
-
651
- .pr-status-indicator {
652
- width: 40px;
653
- height: 40px;
654
- border-radius: 10px;
655
- display: flex;
656
- align-items: center;
657
- justify-content: center;
658
- flex-shrink: 0;
659
- font-size: 1.1rem;
660
- background: var(--accent-open-dim);
661
- color: var(--accent-open);
662
- }
663
-
664
- .pr-item.has-issues .pr-status-indicator {
665
- background: var(--accent-error-dim);
666
- color: var(--accent-error);
667
- animation: attention-pulse 2s ease-in-out infinite;
668
- }
669
-
670
- .pr-item.stale .pr-status-indicator {
671
- background: var(--accent-warning-dim);
672
- color: var(--accent-warning);
673
- }
674
-
675
- @keyframes attention-pulse {
676
- 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
677
- 50% { box-shadow: 0 0 0 6px rgba(248, 81, 73, 0); }
678
- }
679
-
680
- .pr-content { flex: 1; min-width: 0; }
681
-
682
- .pr-title-row {
683
- display: flex;
684
- align-items: center;
685
- gap: 0.5rem;
686
- margin-bottom: 0.25rem;
687
- }
688
-
689
- .pr-title {
690
- font-size: 0.9rem;
691
- font-weight: 500;
692
- color: var(--text-primary);
693
- text-decoration: none;
694
- white-space: nowrap;
695
- overflow: hidden;
696
- text-overflow: ellipsis;
697
- }
698
-
699
- .pr-title:hover { color: var(--accent-info); }
700
-
701
- .pr-repo {
702
- font-family: 'Geist Mono', monospace;
703
- font-size: 0.75rem;
704
- color: var(--text-muted);
705
- flex-shrink: 0;
706
- }
707
-
708
- .pr-badges {
709
- display: flex;
710
- flex-wrap: wrap;
711
- gap: 0.5rem;
712
- }
713
-
714
- .badge {
715
- font-family: 'Geist Mono', monospace;
716
- font-size: 0.65rem;
717
- font-weight: 500;
718
- padding: 0.2rem 0.5rem;
719
- border-radius: 4px;
720
- text-transform: uppercase;
721
- letter-spacing: 0.03em;
722
- }
723
-
724
- .badge-ci-failing { background: var(--accent-error-dim); color: var(--accent-error); }
725
- .badge-conflict { background: rgba(218, 54, 51, 0.15); color: var(--accent-conflict); }
726
- .badge-needs-response { background: var(--accent-warning-dim); color: var(--accent-warning); }
727
- .badge-stale { background: var(--accent-warning-dim); color: var(--accent-warning); }
728
- .badge-passing { background: var(--accent-open-dim); color: var(--accent-open); }
729
- .badge-pending { background: var(--accent-info-dim); color: var(--accent-info); }
730
- .badge-days { background: var(--bg-elevated); color: var(--text-muted); }
731
- .badge-changes-requested { background: var(--accent-warning-dim); color: var(--accent-warning); }
732
- .badge-changes-addressed { background: var(--accent-info-dim); color: var(--accent-info); }
733
-
734
- .pr-activity {
735
- font-family: 'Geist Mono', monospace;
736
- font-size: 0.7rem;
737
- color: var(--text-muted);
738
- margin-left: auto;
739
- text-align: right;
740
- flex-shrink: 0;
741
- }
742
-
743
- .empty-state {
744
- display: flex;
745
- flex-direction: column;
746
- align-items: center;
747
- justify-content: center;
748
- padding: 3rem;
749
- color: var(--text-muted);
750
- }
751
-
752
- .empty-state-icon {
753
- font-size: 2.5rem;
754
- margin-bottom: 1rem;
755
- opacity: 0.5;
756
- }
757
-
758
- .footer {
759
- text-align: center;
760
- padding-top: 1.5rem;
761
- border-top: 1px solid var(--border-muted);
762
- margin-top: 1.5rem;
763
- }
764
-
765
- .footer p {
766
- font-family: 'Geist Mono', monospace;
767
- font-size: 0.7rem;
768
- color: var(--text-muted);
769
- }
770
-
771
- @keyframes fadeInUp {
772
- from { opacity: 0; transform: translateY(12px); }
773
- to { opacity: 1; transform: translateY(0); }
774
- }
775
-
776
- .stats-grid, .health-section, .pr-list-section {
777
- animation: fadeInUp 0.35s ease;
778
- }
779
-
780
- .theme-toggle {
781
- background: var(--bg-elevated);
782
- border: 1px solid var(--border-muted);
783
- border-radius: 8px;
784
- padding: 0.4rem 0.6rem;
785
- cursor: pointer;
786
- color: var(--text-secondary);
787
- display: flex;
788
- align-items: center;
789
- gap: 0.4rem;
790
- font-family: 'Geist Mono', monospace;
791
- font-size: 0.7rem;
792
- transition: background 0.2s ease, color 0.2s ease;
793
- }
794
-
795
- .theme-toggle:hover {
796
- background: var(--bg-surface);
797
- color: var(--text-primary);
798
- }
799
-
800
- .theme-toggle svg { flex-shrink: 0; }
801
-
802
- .header-controls {
803
- display: flex;
804
- align-items: center;
805
- gap: 0.75rem;
806
- }
807
-
808
- .filter-toolbar {
809
- display: flex;
810
- align-items: center;
811
- gap: 0.75rem;
812
- padding: 0.75rem 1rem;
813
- background: var(--bg-surface);
814
- border: 1px solid var(--border-muted);
815
- border-radius: 10px;
816
- margin-bottom: 1.25rem;
817
- flex-wrap: wrap;
818
- }
819
-
820
- .filter-toolbar label {
821
- font-family: 'Geist Mono', monospace;
822
- font-size: 0.7rem;
823
- color: var(--text-muted);
824
- text-transform: uppercase;
825
- letter-spacing: 0.04em;
826
- flex-shrink: 0;
827
- }
828
-
829
- .filter-search {
830
- flex: 1;
831
- min-width: 180px;
832
- padding: 0.4rem 0.75rem;
833
- background: var(--bg-elevated);
834
- border: 1px solid var(--border-muted);
835
- border-radius: 6px;
836
- color: var(--text-primary);
837
- font-family: 'Geist', sans-serif;
838
- font-size: 0.8rem;
839
- outline: none;
840
- transition: border-color 0.2s ease;
841
- }
842
-
843
- .filter-search:focus {
844
- border-color: var(--accent-info);
845
- }
846
-
847
- .filter-search::placeholder {
848
- color: var(--text-muted);
849
- }
850
-
851
- .filter-select {
852
- padding: 0.4rem 0.75rem;
853
- background: var(--bg-elevated);
854
- border: 1px solid var(--border-muted);
855
- border-radius: 6px;
856
- color: var(--text-primary);
857
- font-family: 'Geist', sans-serif;
858
- font-size: 0.8rem;
859
- outline: none;
860
- cursor: pointer;
861
- transition: border-color 0.2s ease;
862
- }
863
-
864
- .filter-select:focus {
865
- border-color: var(--accent-info);
866
- }
867
-
868
- .filter-count {
869
- font-family: 'Geist Mono', monospace;
870
- font-size: 0.7rem;
871
- color: var(--text-muted);
872
- margin-left: auto;
873
- flex-shrink: 0;
874
- }
875
-
876
- .pr-item[data-hidden="true"],
877
- .health-item[data-hidden="true"] {
878
- display: none;
879
- }
48
+ <style>${DASHBOARD_CSS}
880
49
  </style>
881
50
  </head>
882
51
  <body>
@@ -1014,15 +183,15 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1014
183
  <span class="health-badge">${actionRequired.length} issue${actionRequired.length !== 1 ? 's' : ''}</span>
1015
184
  </div>
1016
185
  <div class="health-items">
1017
- ${renderHealthItems(digest.prsNeedingResponse || [], 'needs-response', SVG.comment, 'Needs Response', (pr) => pr.lastMaintainerComment
186
+ ${renderHealthItems(digest.prsNeedingResponse || [], 'needs-response', SVG_ICONS.comment, 'Needs Response', (pr) => pr.lastMaintainerComment
1018
187
  ? `@${escapeHtml(pr.lastMaintainerComment.author)}: ${truncateTitle(pr.lastMaintainerComment.body, 40)}`
1019
188
  : truncateTitle(pr.title))}
1020
- ${renderHealthItems(digest.needsChangesPRs || [], 'needs-changes', SVG.edit, 'Needs Changes', titleMeta)}
1021
- ${renderHealthItems(digest.ciFailingPRs || [], 'ci-failing', SVG.xCircle, 'CI Failing', titleMeta)}
1022
- ${renderHealthItems(digest.mergeConflictPRs || [], 'conflict', SVG.conflict, 'Merge Conflict', titleMeta)}
1023
- ${renderHealthItems(digest.incompleteChecklistPRs || [], 'incomplete-checklist', SVG.checklist, (pr) => `Incomplete Checklist${pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : ''}`, titleMeta)}
1024
- ${renderHealthItems(digest.missingRequiredFilesPRs || [], 'missing-files', SVG.file, 'Missing Required Files', (pr) => (pr.missingRequiredFiles ? escapeHtml(pr.missingRequiredFiles.join(', ')) : truncateTitle(pr.title)))}
1025
- ${renderHealthItems(digest.needsRebasePRs || [], 'needs-rebase', SVG.refresh, (pr) => `Needs Rebase${pr.commitsBehindUpstream ? ` (${pr.commitsBehindUpstream} behind)` : ''}`, titleMeta)}
189
+ ${renderHealthItems(digest.needsChangesPRs || [], 'needs-changes', SVG_ICONS.edit, 'Needs Changes', titleMeta)}
190
+ ${renderHealthItems(digest.ciFailingPRs || [], 'ci-failing', SVG_ICONS.xCircle, 'CI Failing', titleMeta)}
191
+ ${renderHealthItems(digest.mergeConflictPRs || [], 'conflict', SVG_ICONS.conflict, 'Merge Conflict', titleMeta)}
192
+ ${renderHealthItems(digest.incompleteChecklistPRs || [], 'incomplete-checklist', SVG_ICONS.checklist, (pr) => `Incomplete Checklist${pr.checklistStats ? ` (${pr.checklistStats.checked}/${pr.checklistStats.total})` : ''}`, titleMeta)}
193
+ ${renderHealthItems(digest.missingRequiredFilesPRs || [], 'missing-files', SVG_ICONS.file, 'Missing Required Files', (pr) => (pr.missingRequiredFiles ? escapeHtml(pr.missingRequiredFiles.join(', ')) : truncateTitle(pr.title)))}
194
+ ${renderHealthItems(digest.needsRebasePRs || [], 'needs-rebase', SVG_ICONS.refresh, (pr) => `Needs Rebase${pr.commitsBehindUpstream ? ` (${pr.commitsBehindUpstream} behind)` : ''}`, titleMeta)}
1026
195
  </div>
1027
196
  </section>
1028
197
  `
@@ -1040,10 +209,10 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1040
209
  <span class="health-badge" style="background: var(--accent-info-dim); color: var(--accent-info);">${waitingOnOthers.length} PR${waitingOnOthers.length !== 1 ? 's' : ''}</span>
1041
210
  </div>
1042
211
  <div class="health-items">
1043
- ${renderHealthItems(digest.changesAddressedPRs || [], 'changes-addressed', SVG.checkCircle, 'Changes Addressed', (pr) => `Awaiting re-review${pr.lastMaintainerComment ? ` from @${escapeHtml(pr.lastMaintainerComment.author)}` : ''}`)}
1044
- ${renderHealthItems(digest.waitingOnMaintainerPRs || [], 'waiting-maintainer', SVG.clock, 'Waiting on Maintainer', titleMeta)}
1045
- ${renderHealthItems(digest.ciBlockedPRs || [], 'ci-blocked', SVG.lock, 'CI Blocked', titleMeta)}
1046
- ${renderHealthItems(digest.ciNotRunningPRs || [], 'ci-not-running', SVG.infoCircle, 'CI Not Running', titleMeta)}
212
+ ${renderHealthItems(digest.changesAddressedPRs || [], 'changes-addressed', SVG_ICONS.checkCircle, 'Changes Addressed', (pr) => `Awaiting re-review${pr.lastMaintainerComment ? ` from @${escapeHtml(pr.lastMaintainerComment.author)}` : ''}`)}
213
+ ${renderHealthItems(digest.waitingOnMaintainerPRs || [], 'waiting-maintainer', SVG_ICONS.clock, 'Waiting on Maintainer', titleMeta)}
214
+ ${renderHealthItems(digest.ciBlockedPRs || [], 'ci-blocked', SVG_ICONS.lock, 'CI Blocked', titleMeta)}
215
+ ${renderHealthItems(digest.ciNotRunningPRs || [], 'ci-not-running', SVG_ICONS.infoCircle, 'CI Not Running', titleMeta)}
1047
216
  </div>
1048
217
  </section>
1049
218
  `
@@ -1071,7 +240,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1071
240
  <section class="health-section" style="animation-delay: 0.15s;">
1072
241
  <div class="health-header">
1073
242
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent-merged)" stroke-width="2">
1074
- ${SVG.gitMerge}
243
+ ${SVG_ICONS.gitMerge}
1075
244
  </svg>
1076
245
  <h2>Recently Merged</h2>
1077
246
  <span class="health-badge" style="background: var(--accent-merged-dim); color: var(--accent-merged);">${recentlyMerged.length} merged</span>
@@ -1082,7 +251,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1082
251
  <div class="health-item" style="border-left-color: var(--accent-merged);" data-status="merged" data-repo="${escapeHtml(pr.repo)}" data-title="${escapeHtml(pr.title.toLowerCase())}">
1083
252
  <div class="health-icon" style="background: var(--accent-merged-dim); color: var(--accent-merged);">
1084
253
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1085
- ${SVG.gitMerge}
254
+ ${SVG_ICONS.gitMerge}
1086
255
  </svg>
1087
256
  </div>
1088
257
  <div class="health-content">
@@ -1137,13 +306,13 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1137
306
  <section class="health-section" style="animation-delay: 0.25s;">
1138
307
  <div class="health-header">
1139
308
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent-info)" stroke-width="2">
1140
- ${SVG.bell}
309
+ ${SVG_ICONS.bell}
1141
310
  </svg>
1142
311
  <h2>Auto-Unshelved</h2>
1143
312
  <span class="health-badge" style="background: var(--accent-info-dim); color: var(--accent-info);">${autoUnshelvedPRs.length} unshelved</span>
1144
313
  </div>
1145
314
  <div class="health-items">
1146
- ${renderHealthItems(autoUnshelvedPRs, 'auto-unshelved', SVG.bell, (pr) => 'Auto-Unshelved (' + pr.status.replace(/_/g, ' ') + ')', titleMeta)}
315
+ ${renderHealthItems(autoUnshelvedPRs, 'auto-unshelved', SVG_ICONS.bell, (pr) => 'Auto-Unshelved (' + pr.status.replace(/_/g, ' ') + ')', titleMeta)}
1147
316
  </div>
1148
317
  </section>
1149
318
  `
@@ -1154,7 +323,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1154
323
  <section class="health-section" style="animation-delay: 0.3s;">
1155
324
  <div class="health-header">
1156
325
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--accent-info)" stroke-width="2">
1157
- ${SVG.comment}
326
+ ${SVG_ICONS.comment}
1158
327
  </svg>
1159
328
  <h2>Issue Conversations</h2>
1160
329
  <span class="health-badge" style="background: var(--accent-info-dim); color: var(--accent-info);">${issueResponses.length} repl${issueResponses.length !== 1 ? 'ies' : 'y'}</span>
@@ -1165,7 +334,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1165
334
  <div class="health-item changes-addressed">
1166
335
  <div class="health-icon" style="background: var(--accent-info-dim); color: var(--accent-info);">
1167
336
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1168
- ${SVG.comment}
337
+ ${SVG_ICONS.comment}
1169
338
  </svg>
1170
339
  </div>
1171
340
  <div class="health-content">
@@ -1318,7 +487,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1318
487
  <div class="pr-item" data-status="shelved" data-repo="${escapeHtml(pr.repo)}" data-title="${escapeHtml(pr.title.toLowerCase())}">
1319
488
  <div class="pr-status-indicator" style="background: rgba(110, 118, 129, 0.1); color: var(--text-muted);">
1320
489
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1321
- ${SVG.box}
490
+ ${SVG_ICONS.box}
1322
491
  </svg>
1323
492
  </div>
1324
493
  <div class="pr-content">
@@ -1347,280 +516,7 @@ export function generateDashboardHtml(stats, monthlyMerged, monthlyClosed, month
1347
516
  </div>
1348
517
 
1349
518
  <script>
1350
- // === Theme Toggle ===
1351
- (function() {
1352
- var html = document.documentElement;
1353
- var toggle = document.getElementById('themeToggle');
1354
- var sunIcon = document.getElementById('themeIconSun');
1355
- var moonIcon = document.getElementById('themeIconMoon');
1356
- var label = document.getElementById('themeLabel');
1357
-
1358
- function getEffectiveTheme() {
1359
- try {
1360
- var stored = localStorage.getItem('oss-dashboard-theme');
1361
- if (stored === 'light' || stored === 'dark') return stored;
1362
- } catch (e) { /* localStorage unavailable (private browsing) */ }
1363
- return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
1364
- }
1365
-
1366
- function applyTheme(theme) {
1367
- html.setAttribute('data-theme', theme);
1368
- if (theme === 'light') {
1369
- sunIcon.style.display = 'none';
1370
- moonIcon.style.display = 'block';
1371
- label.textContent = 'Dark';
1372
- } else {
1373
- sunIcon.style.display = 'block';
1374
- moonIcon.style.display = 'none';
1375
- label.textContent = 'Light';
1376
- }
1377
- }
1378
-
1379
- applyTheme(getEffectiveTheme());
1380
-
1381
- toggle.addEventListener('click', function() {
1382
- var current = html.getAttribute('data-theme');
1383
- var next = current === 'dark' ? 'light' : 'dark';
1384
- try { localStorage.setItem('oss-dashboard-theme', next); } catch (e) { /* private browsing */ }
1385
- applyTheme(next);
1386
- });
1387
- })();
1388
-
1389
- // === Filtering & Search ===
1390
- (function() {
1391
- var searchInput = document.getElementById('searchInput');
1392
- var statusFilter = document.getElementById('statusFilter');
1393
- var repoFilter = document.getElementById('repoFilter');
1394
- var filterCount = document.getElementById('filterCount');
1395
-
1396
- function applyFilters() {
1397
- var query = searchInput.value.toLowerCase().trim();
1398
- var status = statusFilter.value;
1399
- var repo = repoFilter.value;
1400
- var allItems = document.querySelectorAll('.health-item[data-status], .pr-item[data-status]');
1401
- var visible = 0;
1402
- var total = allItems.length;
1403
-
1404
- allItems.forEach(function(item) {
1405
- var itemStatus = item.getAttribute('data-status') || '';
1406
- var itemRepo = item.getAttribute('data-repo') || '';
1407
- var itemTitle = item.getAttribute('data-title') || '';
1408
-
1409
- var matchesStatus = (status === 'all') || (itemStatus === status);
1410
- var matchesRepo = (repo === 'all') || (itemRepo === repo);
1411
- var matchesSearch = !query || itemTitle.indexOf(query) !== -1;
1412
-
1413
- if (matchesStatus && matchesRepo && matchesSearch) {
1414
- item.setAttribute('data-hidden', 'false');
1415
- visible++;
1416
- } else {
1417
- item.setAttribute('data-hidden', 'true');
1418
- }
1419
- });
1420
-
1421
- // Show/hide parent sections if all children are hidden
1422
- var sections = document.querySelectorAll('.health-section, .pr-list-section');
1423
- sections.forEach(function(section) {
1424
- var items = section.querySelectorAll('.health-item[data-status], .pr-item[data-status]');
1425
- if (items.length === 0) return; // sections without filterable items (e.g. empty state)
1426
- var anyVisible = false;
1427
- items.forEach(function(item) {
1428
- if (item.getAttribute('data-hidden') !== 'true') anyVisible = true;
1429
- });
1430
- section.style.display = anyVisible ? '' : 'none';
1431
- });
1432
-
1433
- var isFiltering = (status !== 'all' || repo !== 'all' || query.length > 0);
1434
- filterCount.textContent = isFiltering ? (visible + ' of ' + total + ' items') : '';
1435
- }
1436
-
1437
- searchInput.addEventListener('input', applyFilters);
1438
- statusFilter.addEventListener('change', applyFilters);
1439
- repoFilter.addEventListener('change', applyFilters);
1440
- })();
1441
-
1442
- // === Chart.js Configuration ===
1443
- Chart.defaults.color = '#6e7681';
1444
- Chart.defaults.borderColor = 'rgba(48, 54, 61, 0.4)';
1445
- Chart.defaults.font.family = "'Geist', sans-serif";
1446
- Chart.defaults.font.size = 11;
1447
-
1448
- // === Status Doughnut ===
1449
- new Chart(document.getElementById('statusChart'), {
1450
- type: 'doughnut',
1451
- data: {
1452
- labels: ['Active', 'Shelved', 'Merged', 'Closed'],
1453
- datasets: [{
1454
- data: [${stats.activePRs}, ${stats.shelvedPRs}, ${stats.mergedPRs}, ${stats.closedPRs}],
1455
- backgroundColor: ['#3fb950', '#6e7681', '#a855f7', '#484f58'],
1456
- borderColor: 'rgba(8, 11, 16, 0.8)',
1457
- borderWidth: 2,
1458
- hoverOffset: 8
1459
- }]
1460
- },
1461
- options: {
1462
- responsive: true,
1463
- maintainAspectRatio: false,
1464
- cutout: '65%',
1465
- plugins: {
1466
- legend: {
1467
- position: 'bottom',
1468
- labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } }
1469
- }
1470
- }
1471
- }
1472
- });
1473
-
1474
- // === Repository Breakdown (with "Other" bucket + percentage tooltips) ===
1475
- ${(() => {
1476
- // Filter helper: exclude repos matching excludeRepos/excludeOrgs or below minStars (#216)
1477
- const { excludeRepos: exRepos = [], excludeOrgs: exOrgs, minStars } = state.config;
1478
- const starThreshold = minStars ?? 50;
1479
- const shouldExcludeRepo = (repo) => {
1480
- const repoLower = repo.toLowerCase();
1481
- if (exRepos.some((r) => r.toLowerCase() === repoLower))
1482
- return true;
1483
- if (exOrgs?.some((o) => o.toLowerCase() === repoLower.split('/')[0]))
1484
- return true;
1485
- const score = (state.repoScores || {})[repo];
1486
- // Fail-open: repos without cached star data are shown (not excluded).
1487
- // Unlike issue-discovery (fail-closed), the dashboard shows the user's own
1488
- // contribution history — hiding repos just because a star fetch failed would be confusing.
1489
- if (score?.stargazersCount !== undefined && score.stargazersCount < starThreshold)
1490
- return true;
1491
- return false;
1492
- };
1493
- // Sort repos by total PRs (merged + active + closed) and build "Other" bucket
1494
- const allRepoEntries = Object.entries(
1495
- // Rebuild from full prsByRepo to get all repos, not just top 10
1496
- (() => {
1497
- const all = {};
1498
- for (const pr of digest.openPRs || []) {
1499
- if (shouldExcludeRepo(pr.repo))
1500
- continue;
1501
- if (!all[pr.repo])
1502
- all[pr.repo] = { active: 0, merged: 0, closed: 0 };
1503
- all[pr.repo].active++;
1504
- }
1505
- for (const [repo, score] of Object.entries(state.repoScores || {})) {
1506
- if (shouldExcludeRepo(repo))
1507
- continue;
1508
- if (!all[repo])
1509
- all[repo] = { active: 0, merged: 0, closed: 0 };
1510
- all[repo].merged = score.mergedPRCount;
1511
- all[repo].closed = score.closedWithoutMergeCount;
1512
- }
1513
- return all;
1514
- })()).sort((a, b) => {
1515
- const totalA = a[1].merged + a[1].active + a[1].closed;
1516
- const totalB = b[1].merged + b[1].active + b[1].closed;
1517
- return totalB - totalA;
1518
- });
1519
- const displayRepos = allRepoEntries.slice(0, 10);
1520
- const otherRepos = allRepoEntries.slice(10);
1521
- const grandTotal = allRepoEntries.reduce((sum, [, d]) => sum + d.merged + d.active + d.closed, 0);
1522
- if (otherRepos.length > 0) {
1523
- const otherData = otherRepos.reduce((acc, [, d]) => ({
1524
- active: acc.active + d.active,
1525
- merged: acc.merged + d.merged,
1526
- closed: acc.closed + d.closed,
1527
- }), { active: 0, merged: 0, closed: 0 });
1528
- displayRepos.push(['Other', otherData]);
1529
- }
1530
- const repoLabels = displayRepos.map(([repo]) => (repo === 'Other' ? 'Other' : repo.split('/')[1] || repo));
1531
- const mergedData = displayRepos.map(([, d]) => d.merged);
1532
- const activeData = displayRepos.map(([, d]) => d.active);
1533
- const closedData = displayRepos.map(([, d]) => d.closed);
1534
- return `
1535
- new Chart(document.getElementById('reposChart'), {
1536
- type: 'bar',
1537
- data: {
1538
- labels: ${JSON.stringify(repoLabels)},
1539
- datasets: [
1540
- { label: 'Merged', data: ${JSON.stringify(mergedData)}, backgroundColor: '#a855f7', borderRadius: 3 },
1541
- { label: 'Active', data: ${JSON.stringify(activeData)}, backgroundColor: '#3fb950', borderRadius: 3 },
1542
- { label: 'Closed', data: ${JSON.stringify(closedData)}, backgroundColor: '#484f58', borderRadius: 3 }
1543
- ]
1544
- },
1545
- options: {
1546
- responsive: true,
1547
- maintainAspectRatio: false,
1548
- scales: {
1549
- x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
1550
- y: { stacked: true, grid: { color: 'rgba(48, 54, 61, 0.3)' }, ticks: { stepSize: 1 } }
1551
- },
1552
- plugins: {
1553
- legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } } },
1554
- tooltip: {
1555
- callbacks: {
1556
- afterBody: function(context) {
1557
- const idx = context[0].dataIndex;
1558
- const total = ${JSON.stringify(mergedData)}[idx] + ${JSON.stringify(activeData)}[idx] + ${JSON.stringify(closedData)}[idx];
1559
- const pct = ${grandTotal} > 0 ? ((total / ${grandTotal}) * 100).toFixed(1) : '0.0';
1560
- return pct + '% of all PRs';
1561
- }
1562
- }
1563
- }
1564
- }
1565
- }
1566
- });`;
1567
- })()}
1568
-
1569
- // === Contribution Timeline (grouped bar: Opened/Merged/Closed) ===
1570
- ${(() => {
1571
- // Generate a contiguous range of the last 6 months from today
1572
- // This avoids gaps when historical data spans years (e.g. 2019 and 2026)
1573
- const now = new Date();
1574
- const allMonths = [];
1575
- for (let offset = 5; offset >= 0; offset--) {
1576
- const d = new Date(now.getFullYear(), now.getMonth() - offset, 1);
1577
- allMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
1578
- }
1579
- return `
1580
- const timelineMonths = ${JSON.stringify(allMonths)};
1581
- const openedData = ${JSON.stringify(monthlyOpened)};
1582
- const mergedData = ${JSON.stringify(monthlyMerged)};
1583
- const closedData = ${JSON.stringify(monthlyClosed)};
1584
- new Chart(document.getElementById('monthlyChart'), {
1585
- type: 'bar',
1586
- data: {
1587
- labels: timelineMonths,
1588
- datasets: [
1589
- {
1590
- label: 'Opened',
1591
- data: timelineMonths.map(m => openedData[m] || 0),
1592
- backgroundColor: '#58a6ff',
1593
- borderRadius: 3
1594
- },
1595
- {
1596
- label: 'Merged',
1597
- data: timelineMonths.map(m => mergedData[m] || 0),
1598
- backgroundColor: '#a855f7',
1599
- borderRadius: 3
1600
- },
1601
- {
1602
- label: 'Closed',
1603
- data: timelineMonths.map(m => closedData[m] || 0),
1604
- backgroundColor: '#484f58',
1605
- borderRadius: 3
1606
- }
1607
- ]
1608
- },
1609
- options: {
1610
- responsive: true,
1611
- maintainAspectRatio: false,
1612
- scales: {
1613
- x: { grid: { display: false } },
1614
- y: { grid: { color: 'rgba(48, 54, 61, 0.3)' }, beginAtZero: true, ticks: { stepSize: 1 } }
1615
- },
1616
- plugins: {
1617
- legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } } }
1618
- },
1619
- interaction: { intersect: false, mode: 'index' }
1620
- }
1621
- });`;
1622
- })()}
1623
-
519
+ ${generateDashboardScripts(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state)}
1624
520
  </script>
1625
521
  </body>
1626
522
  </html>`;