@oss-autopilot/core 0.45.0 → 0.46.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.bundle.cjs +53 -1400
- package/dist/cli.bundle.cjs.map +4 -4
- package/dist/commands/dashboard-lifecycle.js +1 -1
- package/dist/commands/dashboard-process.d.ts +19 -0
- package/dist/commands/dashboard-process.js +93 -0
- package/dist/commands/dashboard-server.d.ts +1 -15
- package/dist/commands/dashboard-server.js +27 -84
- package/dist/commands/dashboard.d.ts +0 -10
- package/dist/commands/dashboard.js +1 -27
- package/dist/commands/index.d.ts +52 -8
- package/dist/commands/index.js +57 -9
- package/dist/commands/rate-limiter.d.ts +31 -0
- package/dist/commands/rate-limiter.js +36 -0
- package/dist/commands/startup.d.ts +1 -1
- package/dist/commands/startup.js +3 -19
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/utils.d.ts +0 -9
- package/dist/core/utils.js +0 -11
- package/dist/formatters/json.d.ts +0 -2
- package/package.json +1 -1
- package/dist/commands/dashboard-components.d.ts +0 -33
- package/dist/commands/dashboard-components.js +0 -57
- package/dist/commands/dashboard-formatters.d.ts +0 -12
- package/dist/commands/dashboard-formatters.js +0 -19
- package/dist/commands/dashboard-scripts.d.ts +0 -7
- package/dist/commands/dashboard-scripts.js +0 -274
- package/dist/commands/dashboard-styles.d.ts +0 -5
- package/dist/commands/dashboard-styles.js +0 -765
- package/dist/commands/dashboard-templates.d.ts +0 -12
- package/dist/commands/dashboard-templates.js +0 -470
package/dist/commands/startup.js
CHANGED
|
@@ -12,7 +12,6 @@ import { getStateManager, getGitHubToken, getCLIVersion, detectGitHubUsername }
|
|
|
12
12
|
import { errorMessage } from '../core/errors.js';
|
|
13
13
|
import { executeDailyCheck } from './daily.js';
|
|
14
14
|
import { launchDashboardServer } from './dashboard-lifecycle.js';
|
|
15
|
-
import { writeDashboardFromState } from './dashboard.js';
|
|
16
15
|
/**
|
|
17
16
|
* Parse issueListPath from a config file's YAML frontmatter.
|
|
18
17
|
* Returns the path string or undefined if not found.
|
|
@@ -121,7 +120,7 @@ export function openInBrowser(url) {
|
|
|
121
120
|
* Returns StartupOutput with one of three shapes:
|
|
122
121
|
* 1. Setup incomplete: { version, setupComplete: false }
|
|
123
122
|
* 2. Auth failure: { version, setupComplete: true, authError: "..." }
|
|
124
|
-
* 3. Success: { version, setupComplete: true, daily, dashboardUrl?,
|
|
123
|
+
* 3. Success: { version, setupComplete: true, daily, dashboardUrl?, issueList? }
|
|
125
124
|
*
|
|
126
125
|
* Errors from the daily check propagate to the caller.
|
|
127
126
|
*/
|
|
@@ -157,22 +156,10 @@ export async function runStartup() {
|
|
|
157
156
|
}
|
|
158
157
|
// 3. Run daily check
|
|
159
158
|
const daily = await executeDailyCheck(token);
|
|
160
|
-
// 4. Launch interactive SPA dashboard
|
|
159
|
+
// 4. Launch interactive SPA dashboard
|
|
161
160
|
// Skip opening on first run (0 PRs) — the welcome flow handles onboarding
|
|
162
161
|
let dashboardUrl;
|
|
163
|
-
let dashboardPath;
|
|
164
162
|
let dashboardOpened = false;
|
|
165
|
-
function tryStaticHtmlFallback() {
|
|
166
|
-
try {
|
|
167
|
-
dashboardPath = writeDashboardFromState();
|
|
168
|
-
openInBrowser(dashboardPath);
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
catch (htmlError) {
|
|
172
|
-
console.error('[STARTUP] Static HTML dashboard fallback also failed:', errorMessage(htmlError));
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
163
|
if (daily.digest.summary.totalActivePRs > 0) {
|
|
177
164
|
try {
|
|
178
165
|
const spaResult = await launchDashboardServer();
|
|
@@ -182,13 +169,11 @@ export async function runStartup() {
|
|
|
182
169
|
dashboardOpened = true;
|
|
183
170
|
}
|
|
184
171
|
else {
|
|
185
|
-
console.error('[STARTUP] Dashboard SPA assets not found
|
|
186
|
-
dashboardOpened = tryStaticHtmlFallback();
|
|
172
|
+
console.error('[STARTUP] Dashboard SPA assets not found. Build with: cd packages/dashboard && pnpm run build');
|
|
187
173
|
}
|
|
188
174
|
}
|
|
189
175
|
catch (error) {
|
|
190
176
|
console.error('[STARTUP] SPA dashboard launch failed:', errorMessage(error));
|
|
191
|
-
dashboardOpened = tryStaticHtmlFallback();
|
|
192
177
|
}
|
|
193
178
|
}
|
|
194
179
|
// Append dashboard status to brief summary (only startup opens the browser, not daily)
|
|
@@ -203,7 +188,6 @@ export async function runStartup() {
|
|
|
203
188
|
autoDetected,
|
|
204
189
|
daily,
|
|
205
190
|
dashboardUrl,
|
|
206
|
-
dashboardPath,
|
|
207
191
|
issueList,
|
|
208
192
|
};
|
|
209
193
|
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { IssueDiscovery, type IssueCandidate, type SearchPriority, isDocOnlyIssu
|
|
|
8
8
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
9
9
|
export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
|
|
10
10
|
export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
|
|
11
|
-
export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir,
|
|
11
|
+
export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, DEFAULT_CONCURRENCY, } from './utils.js';
|
|
12
12
|
export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, } from './errors.js';
|
|
13
13
|
export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
|
|
14
14
|
export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
|
package/dist/core/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { IssueDiscovery, isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS, } fro
|
|
|
8
8
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
9
9
|
export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
|
|
10
10
|
export { getOctokit, checkRateLimit } from './github.js';
|
|
11
|
-
export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir,
|
|
11
|
+
export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, DEFAULT_CONCURRENCY, } from './utils.js';
|
|
12
12
|
export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, } from './errors.js';
|
|
13
13
|
export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
|
|
14
14
|
export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -54,15 +54,6 @@ export declare function getBackupDir(): string;
|
|
|
54
54
|
* // "/Users/you/.oss-autopilot/cache"
|
|
55
55
|
*/
|
|
56
56
|
export declare function getCacheDir(): string;
|
|
57
|
-
/**
|
|
58
|
-
* Returns the path to the static HTML dashboard file (~/.oss-autopilot/dashboard.html).
|
|
59
|
-
* Used as a fallback when the interactive SPA dashboard cannot be launched.
|
|
60
|
-
*
|
|
61
|
-
* @example
|
|
62
|
-
* const dashPath = getDashboardPath();
|
|
63
|
-
* // "/Users/you/.oss-autopilot/dashboard.html"
|
|
64
|
-
*/
|
|
65
|
-
export declare function getDashboardPath(): string;
|
|
66
57
|
/**
|
|
67
58
|
* Represents a parsed GitHub pull request or issue URL.
|
|
68
59
|
*
|
package/dist/core/utils.js
CHANGED
|
@@ -84,17 +84,6 @@ export function getCacheDir() {
|
|
|
84
84
|
}
|
|
85
85
|
return dir;
|
|
86
86
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Returns the path to the static HTML dashboard file (~/.oss-autopilot/dashboard.html).
|
|
89
|
-
* Used as a fallback when the interactive SPA dashboard cannot be launched.
|
|
90
|
-
*
|
|
91
|
-
* @example
|
|
92
|
-
* const dashPath = getDashboardPath();
|
|
93
|
-
* // "/Users/you/.oss-autopilot/dashboard.html"
|
|
94
|
-
*/
|
|
95
|
-
export function getDashboardPath() {
|
|
96
|
-
return path.join(getDataDir(), 'dashboard.html');
|
|
97
|
-
}
|
|
98
87
|
// Validation patterns for GitHub owner and repo names
|
|
99
88
|
const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
100
89
|
const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
|
@@ -200,8 +200,6 @@ export interface StartupOutput {
|
|
|
200
200
|
daily?: DailyOutput;
|
|
201
201
|
/** URL of the interactive SPA dashboard server, when running (e.g., "http://localhost:3000") */
|
|
202
202
|
dashboardUrl?: string;
|
|
203
|
-
/** Path to the static HTML dashboard file (fallback when SPA cannot launch) */
|
|
204
|
-
dashboardPath?: string;
|
|
205
203
|
issueList?: IssueListInfo;
|
|
206
204
|
}
|
|
207
205
|
/** A single parsed issue from a markdown list (#82) */
|
package/package.json
CHANGED
|
@@ -1,33 +0,0 @@
|
|
|
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;
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dashboard HTML formatting helpers.
|
|
3
|
-
* Used by dashboard-templates.ts and dashboard-components.ts for static HTML generation.
|
|
4
|
-
*/
|
|
5
|
-
/**
|
|
6
|
-
* Escape HTML special characters to prevent XSS when interpolating
|
|
7
|
-
* user-controlled content (e.g. PR titles, comment bodies, author names) into HTML.
|
|
8
|
-
* Note: This escapes HTML entity characters only. It does not sanitize URL schemes
|
|
9
|
-
* (e.g., javascript:) — callers placing values in href attributes should validate
|
|
10
|
-
* the URL scheme if the source is untrusted. GitHub API URLs are trusted.
|
|
11
|
-
*/
|
|
12
|
-
export declare function escapeHtml(text: string): string;
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dashboard HTML formatting helpers.
|
|
3
|
-
* Used by dashboard-templates.ts and dashboard-components.ts for static HTML generation.
|
|
4
|
-
*/
|
|
5
|
-
/**
|
|
6
|
-
* Escape HTML special characters to prevent XSS when interpolating
|
|
7
|
-
* user-controlled content (e.g. PR titles, comment bodies, author names) into HTML.
|
|
8
|
-
* Note: This escapes HTML entity characters only. It does not sanitize URL schemes
|
|
9
|
-
* (e.g., javascript:) — callers placing values in href attributes should validate
|
|
10
|
-
* the URL scheme if the source is untrusted. GitHub API URLs are trusted.
|
|
11
|
-
*/
|
|
12
|
-
export function escapeHtml(text) {
|
|
13
|
-
return text
|
|
14
|
-
.replace(/&/g, '&')
|
|
15
|
-
.replace(/</g, '<')
|
|
16
|
-
.replace(/>/g, '>')
|
|
17
|
-
.replace(/"/g, '"')
|
|
18
|
-
.replace(/'/g, ''');
|
|
19
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
|
|
3
|
-
*/
|
|
4
|
-
import { type DailyDigest, type AgentState } from '../core/types.js';
|
|
5
|
-
import type { DashboardStats } from './dashboard-data.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;
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side JavaScript for the dashboard: theme toggle, filtering, Chart.js charts.
|
|
3
|
-
*/
|
|
4
|
-
import { isBelowMinStars } from '../core/types.js';
|
|
5
|
-
/** Static client-side JS: theme toggle + filter/search logic. */
|
|
6
|
-
const THEME_AND_FILTER_SCRIPT = `
|
|
7
|
-
// === Theme Toggle ===
|
|
8
|
-
(function() {
|
|
9
|
-
var html = document.documentElement;
|
|
10
|
-
var toggle = document.getElementById('themeToggle');
|
|
11
|
-
var sunIcon = document.getElementById('themeIconSun');
|
|
12
|
-
var moonIcon = document.getElementById('themeIconMoon');
|
|
13
|
-
var label = document.getElementById('themeLabel');
|
|
14
|
-
|
|
15
|
-
function getEffectiveTheme() {
|
|
16
|
-
try {
|
|
17
|
-
var stored = localStorage.getItem('oss-dashboard-theme');
|
|
18
|
-
if (stored === 'light' || stored === 'dark') return stored;
|
|
19
|
-
} catch (e) { /* localStorage unavailable (private browsing) */ }
|
|
20
|
-
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function applyTheme(theme) {
|
|
24
|
-
html.setAttribute('data-theme', theme);
|
|
25
|
-
if (theme === 'light') {
|
|
26
|
-
sunIcon.style.display = 'none';
|
|
27
|
-
moonIcon.style.display = 'block';
|
|
28
|
-
label.textContent = 'Dark';
|
|
29
|
-
} else {
|
|
30
|
-
sunIcon.style.display = 'block';
|
|
31
|
-
moonIcon.style.display = 'none';
|
|
32
|
-
label.textContent = 'Light';
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
applyTheme(getEffectiveTheme());
|
|
37
|
-
|
|
38
|
-
toggle.addEventListener('click', function() {
|
|
39
|
-
var current = html.getAttribute('data-theme');
|
|
40
|
-
var next = current === 'dark' ? 'light' : 'dark';
|
|
41
|
-
try { localStorage.setItem('oss-dashboard-theme', next); } catch (e) { /* private browsing */ }
|
|
42
|
-
applyTheme(next);
|
|
43
|
-
});
|
|
44
|
-
})();
|
|
45
|
-
|
|
46
|
-
// === Filtering & Search ===
|
|
47
|
-
(function() {
|
|
48
|
-
var searchInput = document.getElementById('searchInput');
|
|
49
|
-
var statusFilter = document.getElementById('statusFilter');
|
|
50
|
-
var repoFilter = document.getElementById('repoFilter');
|
|
51
|
-
var filterCount = document.getElementById('filterCount');
|
|
52
|
-
|
|
53
|
-
function applyFilters() {
|
|
54
|
-
var query = searchInput.value.toLowerCase().trim();
|
|
55
|
-
var status = statusFilter.value;
|
|
56
|
-
var repo = repoFilter.value;
|
|
57
|
-
var allItems = document.querySelectorAll('.health-item[data-status], .pr-item[data-status]');
|
|
58
|
-
var visible = 0;
|
|
59
|
-
var total = allItems.length;
|
|
60
|
-
|
|
61
|
-
allItems.forEach(function(item) {
|
|
62
|
-
var itemStatus = item.getAttribute('data-status') || '';
|
|
63
|
-
var itemRepo = item.getAttribute('data-repo') || '';
|
|
64
|
-
var itemTitle = item.getAttribute('data-title') || '';
|
|
65
|
-
|
|
66
|
-
var matchesStatus = (status === 'all') || (itemStatus === status);
|
|
67
|
-
var matchesRepo = (repo === 'all') || (itemRepo === repo);
|
|
68
|
-
var matchesSearch = !query || itemTitle.indexOf(query) !== -1;
|
|
69
|
-
|
|
70
|
-
if (matchesStatus && matchesRepo && matchesSearch) {
|
|
71
|
-
item.setAttribute('data-hidden', 'false');
|
|
72
|
-
visible++;
|
|
73
|
-
} else {
|
|
74
|
-
item.setAttribute('data-hidden', 'true');
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// Show/hide parent sections if all children are hidden
|
|
79
|
-
var sections = document.querySelectorAll('.health-section, .pr-list-section');
|
|
80
|
-
sections.forEach(function(section) {
|
|
81
|
-
var items = section.querySelectorAll('.health-item[data-status], .pr-item[data-status]');
|
|
82
|
-
if (items.length === 0) return; // sections without filterable items (e.g. empty state)
|
|
83
|
-
var anyVisible = false;
|
|
84
|
-
items.forEach(function(item) {
|
|
85
|
-
if (item.getAttribute('data-hidden') !== 'true') anyVisible = true;
|
|
86
|
-
});
|
|
87
|
-
section.style.display = anyVisible ? '' : 'none';
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
var isFiltering = (status !== 'all' || repo !== 'all' || query.length > 0);
|
|
91
|
-
filterCount.textContent = isFiltering ? (visible + ' of ' + total + ' items') : '';
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
searchInput.addEventListener('input', applyFilters);
|
|
95
|
-
statusFilter.addEventListener('change', applyFilters);
|
|
96
|
-
repoFilter.addEventListener('change', applyFilters);
|
|
97
|
-
})();
|
|
98
|
-
`;
|
|
99
|
-
/** Generate the Chart.js JavaScript for the dashboard. */
|
|
100
|
-
export function generateDashboardScripts(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state) {
|
|
101
|
-
// === Status Doughnut ===
|
|
102
|
-
const statusChart = `
|
|
103
|
-
Chart.defaults.color = '#6e7681';
|
|
104
|
-
Chart.defaults.borderColor = 'rgba(48, 54, 61, 0.4)';
|
|
105
|
-
Chart.defaults.font.family = "'Geist', sans-serif";
|
|
106
|
-
Chart.defaults.font.size = 11;
|
|
107
|
-
|
|
108
|
-
// === Status Doughnut ===
|
|
109
|
-
new Chart(document.getElementById('statusChart'), {
|
|
110
|
-
type: 'doughnut',
|
|
111
|
-
data: {
|
|
112
|
-
labels: ['Active', 'Shelved', 'Merged', 'Closed'],
|
|
113
|
-
datasets: [{
|
|
114
|
-
data: [${stats.activePRs}, ${stats.shelvedPRs}, ${stats.mergedPRs}, ${stats.closedPRs}],
|
|
115
|
-
backgroundColor: ['#3fb950', '#6e7681', '#a855f7', '#484f58'],
|
|
116
|
-
borderColor: 'rgba(8, 11, 16, 0.8)',
|
|
117
|
-
borderWidth: 2,
|
|
118
|
-
hoverOffset: 8
|
|
119
|
-
}]
|
|
120
|
-
},
|
|
121
|
-
options: {
|
|
122
|
-
responsive: true,
|
|
123
|
-
maintainAspectRatio: false,
|
|
124
|
-
cutout: '65%',
|
|
125
|
-
plugins: {
|
|
126
|
-
legend: {
|
|
127
|
-
position: 'bottom',
|
|
128
|
-
labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } }
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
});`;
|
|
133
|
-
// === Repository Breakdown ===
|
|
134
|
-
const repoChart = (() => {
|
|
135
|
-
// Filter helper: exclude repos below minStars (#216)
|
|
136
|
-
const { minStars } = state.config;
|
|
137
|
-
const starThreshold = minStars ?? 50;
|
|
138
|
-
const shouldExcludeRepo = (repo) => {
|
|
139
|
-
const score = (state.repoScores || {})[repo];
|
|
140
|
-
// Fail-closed: repos without cached star data are excluded from charts.
|
|
141
|
-
// Star data is populated by the daily check; repos appear once stars are fetched.
|
|
142
|
-
return isBelowMinStars(score?.stargazersCount, starThreshold);
|
|
143
|
-
};
|
|
144
|
-
// Sort repos by total PRs (merged + active + closed) and build "Other" bucket
|
|
145
|
-
const allRepoEntries = Object.entries(
|
|
146
|
-
// Rebuild from full prsByRepo to get all repos, not just top 10
|
|
147
|
-
(() => {
|
|
148
|
-
const all = {};
|
|
149
|
-
for (const pr of digest.openPRs || []) {
|
|
150
|
-
if (shouldExcludeRepo(pr.repo))
|
|
151
|
-
continue;
|
|
152
|
-
if (!all[pr.repo])
|
|
153
|
-
all[pr.repo] = { active: 0, merged: 0, closed: 0 };
|
|
154
|
-
all[pr.repo].active++;
|
|
155
|
-
}
|
|
156
|
-
for (const [repo, score] of Object.entries(state.repoScores || {})) {
|
|
157
|
-
if (shouldExcludeRepo(repo))
|
|
158
|
-
continue;
|
|
159
|
-
if (!all[repo])
|
|
160
|
-
all[repo] = { active: 0, merged: 0, closed: 0 };
|
|
161
|
-
all[repo].merged = score.mergedPRCount;
|
|
162
|
-
all[repo].closed = score.closedWithoutMergeCount;
|
|
163
|
-
}
|
|
164
|
-
return all;
|
|
165
|
-
})()).sort((a, b) => {
|
|
166
|
-
const totalA = a[1].merged + a[1].active + a[1].closed;
|
|
167
|
-
const totalB = b[1].merged + b[1].active + b[1].closed;
|
|
168
|
-
return totalB - totalA;
|
|
169
|
-
});
|
|
170
|
-
const displayRepos = allRepoEntries.slice(0, 10);
|
|
171
|
-
const otherRepos = allRepoEntries.slice(10);
|
|
172
|
-
const grandTotal = allRepoEntries.reduce((sum, [, d]) => sum + d.merged + d.active + d.closed, 0);
|
|
173
|
-
if (otherRepos.length > 0) {
|
|
174
|
-
const otherData = otherRepos.reduce((acc, [, d]) => ({
|
|
175
|
-
active: acc.active + d.active,
|
|
176
|
-
merged: acc.merged + d.merged,
|
|
177
|
-
closed: acc.closed + d.closed,
|
|
178
|
-
}), { active: 0, merged: 0, closed: 0 });
|
|
179
|
-
displayRepos.push(['Other', otherData]);
|
|
180
|
-
}
|
|
181
|
-
const repoLabels = displayRepos.map(([repo]) => (repo === 'Other' ? 'Other' : repo.split('/')[1] || repo));
|
|
182
|
-
const mergedData = displayRepos.map(([, d]) => d.merged);
|
|
183
|
-
const activeData = displayRepos.map(([, d]) => d.active);
|
|
184
|
-
const closedData = displayRepos.map(([, d]) => d.closed);
|
|
185
|
-
return `
|
|
186
|
-
new Chart(document.getElementById('reposChart'), {
|
|
187
|
-
type: 'bar',
|
|
188
|
-
data: {
|
|
189
|
-
labels: ${JSON.stringify(repoLabels)},
|
|
190
|
-
datasets: [
|
|
191
|
-
{ label: 'Merged', data: ${JSON.stringify(mergedData)}, backgroundColor: '#a855f7', borderRadius: 3 },
|
|
192
|
-
{ label: 'Active', data: ${JSON.stringify(activeData)}, backgroundColor: '#3fb950', borderRadius: 3 },
|
|
193
|
-
{ label: 'Closed', data: ${JSON.stringify(closedData)}, backgroundColor: '#484f58', borderRadius: 3 }
|
|
194
|
-
]
|
|
195
|
-
},
|
|
196
|
-
options: {
|
|
197
|
-
responsive: true,
|
|
198
|
-
maintainAspectRatio: false,
|
|
199
|
-
scales: {
|
|
200
|
-
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
|
|
201
|
-
y: { stacked: true, grid: { color: 'rgba(48, 54, 61, 0.3)' }, ticks: { stepSize: 1 } }
|
|
202
|
-
},
|
|
203
|
-
plugins: {
|
|
204
|
-
legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } } },
|
|
205
|
-
tooltip: {
|
|
206
|
-
callbacks: {
|
|
207
|
-
afterBody: function(context) {
|
|
208
|
-
const idx = context[0].dataIndex;
|
|
209
|
-
const total = ${JSON.stringify(mergedData)}[idx] + ${JSON.stringify(activeData)}[idx] + ${JSON.stringify(closedData)}[idx];
|
|
210
|
-
const pct = ${grandTotal} > 0 ? ((total / ${grandTotal}) * 100).toFixed(1) : '0.0';
|
|
211
|
-
return pct + '% of all PRs';
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
});`;
|
|
218
|
-
})();
|
|
219
|
-
// === Contribution Timeline ===
|
|
220
|
-
const timelineChart = (() => {
|
|
221
|
-
// Generate a contiguous range of the last 6 months from today
|
|
222
|
-
// This avoids gaps when historical data spans years (e.g. 2019 and 2026)
|
|
223
|
-
const now = new Date();
|
|
224
|
-
const allMonths = [];
|
|
225
|
-
for (let offset = 5; offset >= 0; offset--) {
|
|
226
|
-
const d = new Date(now.getFullYear(), now.getMonth() - offset, 1);
|
|
227
|
-
allMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
|
228
|
-
}
|
|
229
|
-
return `
|
|
230
|
-
const timelineMonths = ${JSON.stringify(allMonths)};
|
|
231
|
-
const openedData = ${JSON.stringify(monthlyOpened)};
|
|
232
|
-
const mergedData = ${JSON.stringify(monthlyMerged)};
|
|
233
|
-
const closedData = ${JSON.stringify(monthlyClosed)};
|
|
234
|
-
new Chart(document.getElementById('monthlyChart'), {
|
|
235
|
-
type: 'bar',
|
|
236
|
-
data: {
|
|
237
|
-
labels: timelineMonths,
|
|
238
|
-
datasets: [
|
|
239
|
-
{
|
|
240
|
-
label: 'Opened',
|
|
241
|
-
data: timelineMonths.map(m => openedData[m] || 0),
|
|
242
|
-
backgroundColor: '#58a6ff',
|
|
243
|
-
borderRadius: 3
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
label: 'Merged',
|
|
247
|
-
data: timelineMonths.map(m => mergedData[m] || 0),
|
|
248
|
-
backgroundColor: '#a855f7',
|
|
249
|
-
borderRadius: 3
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
label: 'Closed',
|
|
253
|
-
data: timelineMonths.map(m => closedData[m] || 0),
|
|
254
|
-
backgroundColor: '#484f58',
|
|
255
|
-
borderRadius: 3
|
|
256
|
-
}
|
|
257
|
-
]
|
|
258
|
-
},
|
|
259
|
-
options: {
|
|
260
|
-
responsive: true,
|
|
261
|
-
maintainAspectRatio: false,
|
|
262
|
-
scales: {
|
|
263
|
-
x: { grid: { display: false } },
|
|
264
|
-
y: { grid: { color: 'rgba(48, 54, 61, 0.3)' }, beginAtZero: true, ticks: { stepSize: 1 } }
|
|
265
|
-
},
|
|
266
|
-
plugins: {
|
|
267
|
-
legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, pointStyle: 'circle', font: { size: 11 } } }
|
|
268
|
-
},
|
|
269
|
-
interaction: { intersect: false, mode: 'index' }
|
|
270
|
-
}
|
|
271
|
-
});`;
|
|
272
|
-
})();
|
|
273
|
-
return THEME_AND_FILTER_SCRIPT + statusChart + '\n' + repoChart + '\n' + timelineChart;
|
|
274
|
-
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dashboard CSS styles: theme variables, layout, component styles.
|
|
3
|
-
* Extracted from dashboard-templates.ts for maintainability.
|
|
4
|
-
*/
|
|
5
|
-
export declare const DASHBOARD_CSS = "\n :root, [data-theme=\"dark\"] {\n --bg-base: #080b10;\n --bg-surface: rgba(22, 27, 34, 0.65);\n --bg-elevated: rgba(28, 33, 40, 0.8);\n --border: rgba(48, 54, 61, 0.6);\n --border-muted: rgba(33, 38, 45, 0.5);\n --text-primary: #e6edf3;\n --text-secondary: #8b949e;\n --text-muted: #6e7681;\n --accent-merged: #a855f7;\n --accent-merged-dim: rgba(168, 85, 247, 0.12);\n --accent-open: #3fb950;\n --accent-open-dim: rgba(63, 185, 80, 0.12);\n --accent-warning: #d29922;\n --accent-warning-dim: rgba(210, 153, 34, 0.12);\n --accent-error: #f85149;\n --accent-error-dim: rgba(248, 81, 73, 0.10);\n --accent-conflict: #da3633;\n --accent-info: #58a6ff;\n --accent-info-dim: rgba(88, 166, 255, 0.08);\n --chart-border: rgba(8, 11, 16, 0.8);\n --chart-grid: rgba(48, 54, 61, 0.3);\n --scrollbar-track: rgba(28, 33, 40, 0.8);\n --scrollbar-thumb: rgba(48, 54, 61, 0.6);\n }\n\n [data-theme=\"light\"] {\n --bg-base: #f6f8fa;\n --bg-surface: rgba(255, 255, 255, 0.85);\n --bg-elevated: rgba(246, 248, 250, 0.95);\n --border: rgba(208, 215, 222, 0.6);\n --border-muted: rgba(216, 222, 228, 0.5);\n --text-primary: #1f2328;\n --text-secondary: #656d76;\n --text-muted: #8b949e;\n --accent-merged: #8250df;\n --accent-merged-dim: rgba(130, 80, 223, 0.1);\n --accent-open: #1a7f37;\n --accent-open-dim: rgba(26, 127, 55, 0.1);\n --accent-warning: #9a6700;\n --accent-warning-dim: rgba(154, 103, 0, 0.1);\n --accent-error: #cf222e;\n --accent-error-dim: rgba(207, 34, 46, 0.08);\n --accent-conflict: #cf222e;\n --accent-info: #0969da;\n --accent-info-dim: rgba(9, 105, 218, 0.08);\n --chart-border: rgba(255, 255, 255, 0.8);\n --chart-grid: rgba(208, 215, 222, 0.4);\n --scrollbar-track: rgba(246, 248, 250, 0.95);\n --scrollbar-thumb: rgba(208, 215, 222, 0.6);\n }\n\n @media (prefers-color-scheme: light) {\n :root:not([data-theme=\"dark\"]) {\n --bg-base: #f6f8fa;\n --bg-surface: rgba(255, 255, 255, 0.85);\n --bg-elevated: rgba(246, 248, 250, 0.95);\n --border: rgba(208, 215, 222, 0.6);\n --border-muted: rgba(216, 222, 228, 0.5);\n --text-primary: #1f2328;\n --text-secondary: #656d76;\n --text-muted: #8b949e;\n --accent-merged: #8250df;\n --accent-merged-dim: rgba(130, 80, 223, 0.1);\n --accent-open: #1a7f37;\n --accent-open-dim: rgba(26, 127, 55, 0.1);\n --accent-warning: #9a6700;\n --accent-warning-dim: rgba(154, 103, 0, 0.1);\n --accent-error: #cf222e;\n --accent-error-dim: rgba(207, 34, 46, 0.08);\n --accent-conflict: #cf222e;\n --accent-info: #0969da;\n --accent-info-dim: rgba(9, 105, 218, 0.08);\n --chart-border: rgba(255, 255, 255, 0.8);\n --chart-grid: rgba(208, 215, 222, 0.4);\n --scrollbar-track: rgba(246, 248, 250, 0.95);\n --scrollbar-thumb: rgba(208, 215, 222, 0.6);\n }\n }\n\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;\n background: var(--bg-base);\n color: var(--text-primary);\n min-height: 100vh;\n line-height: 1.5;\n overflow-x: hidden;\n }\n\n body::before {\n content: '';\n position: fixed;\n top: -20%; left: -10%;\n width: 60%; height: 60%;\n background: radial-gradient(ellipse, rgba(88, 166, 255, 0.06) 0%, transparent 70%);\n pointer-events: none;\n z-index: 0;\n }\n\n body::after {\n content: '';\n position: fixed;\n bottom: -20%; right: -10%;\n width: 50%; height: 50%;\n background: radial-gradient(ellipse, rgba(168, 85, 247, 0.05) 0%, transparent 70%);\n pointer-events: none;\n z-index: 0;\n }\n\n [data-theme=\"light\"] body::before,\n [data-theme=\"light\"] body::after {\n display: none;\n }\n\n .container {\n max-width: 1400px;\n margin: 0 auto;\n padding: 2rem;\n position: relative;\n z-index: 1;\n }\n\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 1.5rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--border-muted);\n }\n\n .header-left {\n display: flex;\n align-items: center;\n gap: 1rem;\n }\n\n .logo {\n width: 44px;\n height: 44px;\n background: linear-gradient(135deg, var(--accent-info) 0%, var(--accent-merged) 50%, #f778ba 100%);\n border-radius: 12px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 1.5rem;\n box-shadow: 0 0 24px rgba(168, 85, 247, 0.3), 0 0 48px rgba(88, 166, 255, 0.15);\n }\n\n .header h1 {\n font-size: 1.75rem;\n font-weight: 600;\n letter-spacing: -0.02em;\n background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);\n -webkit-background-clip: text;\n -webkit-text-fill-color: transparent;\n background-clip: text;\n }\n\n .header-subtitle {\n font-family: 'Geist Mono', monospace;\n font-size: 0.75rem;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.1em;\n }\n\n .timestamp {\n font-family: 'Geist Mono', monospace;\n font-size: 0.8rem;\n color: var(--text-muted);\n display: flex;\n align-items: center;\n gap: 0.5rem;\n }\n\n .timestamp::before {\n content: '';\n width: 8px;\n height: 8px;\n background: var(--accent-open);\n border-radius: 50%;\n animation: pulse 2s ease-in-out infinite;\n }\n\n @keyframes pulse {\n 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(35, 134, 54, 0.4); }\n 50% { opacity: 0.8; box-shadow: 0 0 0 8px rgba(35, 134, 54, 0); }\n }\n\n .stats-grid {\n display: flex;\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 12px;\n margin-bottom: 1.5rem;\n overflow: hidden;\n }\n\n @media (max-width: 768px) {\n .stats-grid { flex-wrap: wrap; }\n .stat-card { flex: 1 1 33%; }\n }\n\n .stat-card {\n flex: 1;\n padding: 1rem 1.25rem;\n position: relative;\n transition: background 0.2s ease;\n }\n\n .stat-card + .stat-card {\n border-left: 1px solid var(--border-muted);\n }\n\n .stat-card:hover {\n background: rgba(255, 255, 255, 0.02);\n }\n\n .stat-card::after {\n content: '';\n position: absolute;\n bottom: 0; left: 0.75rem; right: 0.75rem;\n height: 2px;\n background: var(--accent-color, var(--border));\n border-radius: 2px;\n opacity: 0.7;\n }\n\n .stat-card.active { --accent-color: var(--accent-open); }\n .stat-card.merged { --accent-color: var(--accent-merged); }\n .stat-card.closed { --accent-color: var(--text-muted); }\n .stat-card.rate { --accent-color: var(--accent-info); }\n\n .stat-value {\n font-family: 'Geist Mono', monospace;\n font-size: 1.75rem;\n font-weight: 600;\n line-height: 1;\n margin-bottom: 0.25rem;\n }\n\n .stat-card.active .stat-value { color: var(--accent-open); }\n .stat-card.merged .stat-value { color: var(--accent-merged); }\n .stat-card.closed .stat-value { color: var(--text-muted); }\n .stat-card.rate .stat-value { color: var(--accent-info); }\n\n .stat-label {\n font-size: 0.7rem;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n .health-section {\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n padding: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n .health-header {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n margin-bottom: 1rem;\n }\n\n .health-header h2 {\n font-size: 1rem;\n font-weight: 600;\n color: var(--text-primary);\n }\n\n .health-badge {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n padding: 0.25rem 0.5rem;\n border-radius: 4px;\n background: var(--accent-error-dim);\n color: var(--accent-error);\n }\n\n .health-items {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n gap: 0.75rem;\n }\n\n .health-item {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n padding: 0.75rem 1rem;\n background: var(--bg-elevated);\n border-radius: 8px;\n border-left: 3px solid;\n transition: transform 0.15s ease;\n }\n\n .health-item:hover { transform: translateX(4px); }\n\n .health-item.ci-failing {\n border-left-color: var(--accent-error);\n background: var(--accent-error-dim);\n }\n\n .health-item.conflict {\n border-left-color: var(--accent-conflict);\n background: rgba(218, 54, 51, 0.1);\n }\n\n .health-item.incomplete-checklist {\n border-left-color: var(--accent-info);\n }\n .health-item.needs-response,\n .health-item.needs-changes {\n border-left-color: var(--accent-warning);\n background: var(--accent-warning-dim);\n }\n\n .health-item.changes-addressed,\n .health-item.waiting-maintainer {\n border-left-color: var(--accent-info);\n background: var(--accent-info-dim);\n }\n\n .health-item.ci-not-running {\n border-left-color: var(--text-muted);\n background: rgba(110, 118, 129, 0.1);\n }\n\n .health-item.missing-files {\n border-left-color: var(--accent-warning);\n background: var(--accent-warning-dim);\n }\n\n .health-item.ci-blocked {\n border-left-color: var(--text-muted);\n background: rgba(110, 118, 129, 0.1);\n }\n\n .health-item.needs-rebase {\n border-left-color: var(--accent-warning);\n background: var(--accent-warning-dim);\n }\n\n .health-item.shelved {\n border-left-color: var(--text-muted);\n background: rgba(110, 118, 129, 0.06);\n opacity: 0.6;\n }\n\n .health-item.shelved .health-icon { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); }\n\n .health-item.auto-unshelved {\n border-left-color: var(--accent-info);\n background: var(--accent-info-dim);\n }\n\n .health-item.auto-unshelved .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }\n\n .stat-card.shelved { --accent-color: var(--text-muted); }\n .stat-card.shelved .stat-value { color: var(--text-muted); }\n\n .waiting-section {\n border-color: rgba(88, 166, 255, 0.2);\n }\n\n .health-icon {\n width: 32px;\n height: 32px;\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 1rem;\n flex-shrink: 0;\n }\n\n .health-item.ci-failing .health-icon { background: var(--accent-error-dim); color: var(--accent-error); }\n .health-item.conflict .health-icon { background: rgba(218, 54, 51, 0.15); color: var(--accent-conflict); }\n .health-item.incomplete-checklist .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }\n .health-item.needs-response .health-icon,\n .health-item.needs-changes .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .health-item.changes-addressed .health-icon,\n .health-item.waiting-maintainer .health-icon { background: var(--accent-info-dim); color: var(--accent-info); }\n .health-item.ci-not-running .health-icon { background: rgba(110, 118, 129, 0.15); color: var(--text-muted); }\n .health-item.missing-files .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .health-item.ci-blocked .health-icon { background: rgba(110, 118, 129, 0.15); color: var(--text-muted); }\n .health-item.needs-rebase .health-icon { background: var(--accent-warning-dim); color: var(--accent-warning); }\n\n .health-content { flex: 1; min-width: 0; }\n\n .health-title {\n font-size: 0.85rem;\n font-weight: 500;\n color: var(--text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .health-title a { color: inherit; text-decoration: none; }\n .health-title a:hover { color: var(--accent-info); }\n\n .health-meta {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n }\n\n .health-empty {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 2rem;\n color: var(--text-muted);\n font-size: 0.9rem;\n }\n\n .health-empty::before {\n content: '\\2713';\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n background: var(--accent-open-dim);\n color: var(--accent-open);\n border-radius: 50%;\n margin-right: 0.75rem;\n font-weight: bold;\n }\n\n .main-grid {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n @media (max-width: 1024px) { .main-grid { grid-template-columns: 1fr; } }\n\n .card {\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n overflow: hidden;\n }\n\n .card-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.75rem 1.125rem;\n border-bottom: 1px solid var(--border-muted);\n }\n\n .card-title {\n font-size: 0.75rem;\n font-weight: 600;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n .card-body { padding: 1rem 1.125rem; }\n\n .chart-container {\n position: relative;\n height: 260px;\n }\n\n .pr-list-section {\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n overflow: hidden;\n }\n\n .pr-list-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.75rem 1.125rem;\n border-bottom: 1px solid var(--border-muted);\n }\n\n .pr-list-title {\n font-size: 0.75rem;\n font-weight: 600;\n color: var(--text-secondary);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n\n .pr-count {\n font-family: 'Geist Mono', monospace;\n font-size: 0.75rem;\n padding: 0.25rem 0.5rem;\n background: var(--accent-open-dim);\n color: var(--accent-open);\n border-radius: 4px;\n }\n\n .pr-list {\n max-height: 600px;\n overflow-y: auto;\n }\n\n .pr-list::-webkit-scrollbar { width: 6px; }\n .pr-list::-webkit-scrollbar-track { background: var(--scrollbar-track, var(--bg-elevated)); }\n .pr-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb, var(--border)); border-radius: 3px; }\n\n .pr-item {\n display: flex;\n align-items: flex-start;\n gap: 1rem;\n padding: 1rem 1.25rem;\n border-bottom: 1px solid var(--border-muted);\n transition: background 0.15s ease;\n }\n\n .pr-item:last-child { border-bottom: none; }\n .pr-item:hover { background: var(--bg-elevated); }\n\n .pr-status-indicator {\n width: 40px;\n height: 40px;\n border-radius: 10px;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n font-size: 1.1rem;\n background: var(--accent-open-dim);\n color: var(--accent-open);\n }\n\n .pr-item.has-issues .pr-status-indicator {\n background: var(--accent-error-dim);\n color: var(--accent-error);\n animation: attention-pulse 2s ease-in-out infinite;\n }\n\n .pr-item.stale .pr-status-indicator {\n background: var(--accent-warning-dim);\n color: var(--accent-warning);\n }\n\n @keyframes attention-pulse {\n 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }\n 50% { box-shadow: 0 0 0 6px rgba(248, 81, 73, 0); }\n }\n\n .pr-content { flex: 1; min-width: 0; }\n\n .pr-title-row {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-bottom: 0.25rem;\n }\n\n .pr-title {\n font-size: 0.9rem;\n font-weight: 500;\n color: var(--text-primary);\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .pr-title:hover { color: var(--accent-info); }\n\n .pr-repo {\n font-family: 'Geist Mono', monospace;\n font-size: 0.75rem;\n color: var(--text-muted);\n flex-shrink: 0;\n }\n\n .pr-badges {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n }\n\n .badge {\n font-family: 'Geist Mono', monospace;\n font-size: 0.65rem;\n font-weight: 500;\n padding: 0.2rem 0.5rem;\n border-radius: 4px;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n }\n\n .badge-ci-failing { background: var(--accent-error-dim); color: var(--accent-error); }\n .badge-conflict { background: rgba(218, 54, 51, 0.15); color: var(--accent-conflict); }\n .badge-needs-response { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .badge-stale { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .badge-passing { background: var(--accent-open-dim); color: var(--accent-open); }\n .badge-pending { background: var(--accent-info-dim); color: var(--accent-info); }\n .badge-days { background: var(--bg-elevated); color: var(--text-muted); }\n .badge-changes-requested { background: var(--accent-warning-dim); color: var(--accent-warning); }\n .badge-changes-addressed { background: var(--accent-info-dim); color: var(--accent-info); }\n\n .pr-activity {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n margin-left: auto;\n text-align: right;\n flex-shrink: 0;\n }\n\n .empty-state {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 3rem;\n color: var(--text-muted);\n }\n\n .empty-state-icon {\n font-size: 2.5rem;\n margin-bottom: 1rem;\n opacity: 0.5;\n }\n\n .footer {\n text-align: center;\n padding-top: 1.5rem;\n border-top: 1px solid var(--border-muted);\n margin-top: 1.5rem;\n }\n\n .footer p {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n }\n\n @keyframes fadeInUp {\n from { opacity: 0; transform: translateY(12px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n .stats-grid, .health-section, .pr-list-section {\n animation: fadeInUp 0.35s ease;\n }\n\n .theme-toggle {\n background: var(--bg-elevated);\n border: 1px solid var(--border-muted);\n border-radius: 8px;\n padding: 0.4rem 0.6rem;\n cursor: pointer;\n color: var(--text-secondary);\n display: flex;\n align-items: center;\n gap: 0.4rem;\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n transition: background 0.2s ease, color 0.2s ease;\n }\n\n .theme-toggle:hover {\n background: var(--bg-surface);\n color: var(--text-primary);\n }\n\n .theme-toggle svg { flex-shrink: 0; }\n\n .header-controls {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n }\n\n .filter-toolbar {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n padding: 0.75rem 1rem;\n background: var(--bg-surface);\n border: 1px solid var(--border-muted);\n border-radius: 10px;\n margin-bottom: 1.25rem;\n flex-wrap: wrap;\n }\n\n .filter-toolbar label {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n flex-shrink: 0;\n }\n\n .filter-search {\n flex: 1;\n min-width: 180px;\n padding: 0.4rem 0.75rem;\n background: var(--bg-elevated);\n border: 1px solid var(--border-muted);\n border-radius: 6px;\n color: var(--text-primary);\n font-family: 'Geist', sans-serif;\n font-size: 0.8rem;\n outline: none;\n transition: border-color 0.2s ease;\n }\n\n .filter-search:focus {\n border-color: var(--accent-info);\n }\n\n .filter-search::placeholder {\n color: var(--text-muted);\n }\n\n .filter-select {\n padding: 0.4rem 0.75rem;\n background: var(--bg-elevated);\n border: 1px solid var(--border-muted);\n border-radius: 6px;\n color: var(--text-primary);\n font-family: 'Geist', sans-serif;\n font-size: 0.8rem;\n outline: none;\n cursor: pointer;\n transition: border-color 0.2s ease;\n }\n\n .filter-select:focus {\n border-color: var(--accent-info);\n }\n\n .filter-count {\n font-family: 'Geist Mono', monospace;\n font-size: 0.7rem;\n color: var(--text-muted);\n margin-left: auto;\n flex-shrink: 0;\n }\n\n .pr-item[data-hidden=\"true\"],\n .health-item[data-hidden=\"true\"] {\n display: none;\n }\n";
|