@oss-autopilot/core 0.42.5 → 0.43.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 +237 -25
- package/dist/commands/daily.js +22 -7
- package/dist/commands/dashboard-data.js +19 -5
- package/dist/commands/dashboard-lifecycle.d.ts +22 -0
- package/dist/commands/dashboard-lifecycle.js +87 -0
- package/dist/commands/dashboard-server.d.ts +14 -0
- package/dist/commands/dashboard-server.js +85 -1
- package/dist/commands/dashboard.d.ts +5 -0
- package/dist/commands/dashboard.js +1 -1
- package/dist/commands/startup.d.ts +1 -1
- package/dist/commands/startup.js +44 -12
- package/dist/core/github-stats.d.ts +3 -1
- package/dist/core/github-stats.js +26 -6
- package/dist/formatters/json.d.ts +3 -1
- package/package.json +1 -1
package/dist/cli.bundle.cjs
CHANGED
|
@@ -10847,6 +10847,9 @@ var init_display_utils = __esm({
|
|
|
10847
10847
|
});
|
|
10848
10848
|
|
|
10849
10849
|
// src/core/github-stats.ts
|
|
10850
|
+
function emptyPRCountsResult() {
|
|
10851
|
+
return { repos: /* @__PURE__ */ new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
|
|
10852
|
+
}
|
|
10850
10853
|
function isCachedPRCounts(v) {
|
|
10851
10854
|
if (typeof v !== "object" || v === null) return false;
|
|
10852
10855
|
const obj = v;
|
|
@@ -10854,7 +10857,7 @@ function isCachedPRCounts(v) {
|
|
|
10854
10857
|
}
|
|
10855
10858
|
async function fetchUserPRCounts(octokit, githubUsername, query, label, accumulateRepo) {
|
|
10856
10859
|
if (!githubUsername) {
|
|
10857
|
-
return
|
|
10860
|
+
return emptyPRCountsResult();
|
|
10858
10861
|
}
|
|
10859
10862
|
const cache = getHttpCache();
|
|
10860
10863
|
const cacheKey = `pr-counts:${label}:${githubUsername}`;
|
|
@@ -10875,6 +10878,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
10875
10878
|
const dailyActivityCounts = {};
|
|
10876
10879
|
let page = 1;
|
|
10877
10880
|
let fetched = 0;
|
|
10881
|
+
let totalCount;
|
|
10878
10882
|
while (true) {
|
|
10879
10883
|
const { data } = await octokit.search.issuesAndPullRequests({
|
|
10880
10884
|
q: `is:pr ${query} author:${githubUsername}`,
|
|
@@ -10883,6 +10887,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
10883
10887
|
per_page: 100,
|
|
10884
10888
|
page
|
|
10885
10889
|
});
|
|
10890
|
+
totalCount = data.total_count;
|
|
10886
10891
|
for (const item of data.items) {
|
|
10887
10892
|
const parsed = extractOwnerRepo(item.html_url);
|
|
10888
10893
|
if (!parsed) {
|
|
@@ -10907,11 +10912,17 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
10907
10912
|
}
|
|
10908
10913
|
}
|
|
10909
10914
|
fetched += data.items.length;
|
|
10910
|
-
if (fetched >= data.total_count || fetched >= 1e3 || data.items.length === 0) {
|
|
10915
|
+
if (fetched >= data.total_count || fetched >= 1e3 || data.items.length === 0 || page >= MAX_PAGINATION_PAGES) {
|
|
10911
10916
|
break;
|
|
10912
10917
|
}
|
|
10913
10918
|
page++;
|
|
10914
10919
|
}
|
|
10920
|
+
if (fetched < totalCount && page >= MAX_PAGINATION_PAGES) {
|
|
10921
|
+
warn(
|
|
10922
|
+
MODULE6,
|
|
10923
|
+
`Pagination capped at ${MAX_PAGINATION_PAGES} pages: fetched ${fetched} of ${totalCount} ${label} PRs. Stats may be incomplete for prolific contributors.`
|
|
10924
|
+
);
|
|
10925
|
+
}
|
|
10915
10926
|
debug(MODULE6, `Found ${fetched} ${label} PRs across ${repos.size} repos`);
|
|
10916
10927
|
cache.set(cacheKey, "", {
|
|
10917
10928
|
reposEntries: Array.from(repos.entries()),
|
|
@@ -11020,7 +11031,7 @@ async function fetchRecentlyMergedPRs(octokit, config, days = 7) {
|
|
|
11020
11031
|
}
|
|
11021
11032
|
);
|
|
11022
11033
|
}
|
|
11023
|
-
var MODULE6, PR_COUNTS_CACHE_TTL_MS;
|
|
11034
|
+
var MODULE6, PR_COUNTS_CACHE_TTL_MS, MAX_PAGINATION_PAGES;
|
|
11024
11035
|
var init_github_stats = __esm({
|
|
11025
11036
|
"src/core/github-stats.ts"() {
|
|
11026
11037
|
"use strict";
|
|
@@ -11028,7 +11039,8 @@ var init_github_stats = __esm({
|
|
|
11028
11039
|
init_logger();
|
|
11029
11040
|
init_http_cache();
|
|
11030
11041
|
MODULE6 = "github-stats";
|
|
11031
|
-
PR_COUNTS_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
11042
|
+
PR_COUNTS_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
11043
|
+
MAX_PAGINATION_PAGES = 3;
|
|
11032
11044
|
}
|
|
11033
11045
|
});
|
|
11034
11046
|
|
|
@@ -13476,8 +13488,14 @@ async function fetchPRData(prMonitor, token) {
|
|
|
13476
13488
|
const issueMonitor = new IssueConversationMonitor(token);
|
|
13477
13489
|
const [mergedResult, closedResult, recentlyClosedPRs, recentlyMergedPRs, issueConversationResult] = await Promise.all(
|
|
13478
13490
|
[
|
|
13479
|
-
prMonitor.fetchUserMergedPRCounts()
|
|
13480
|
-
|
|
13491
|
+
prMonitor.fetchUserMergedPRCounts().catch((err) => {
|
|
13492
|
+
console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
|
|
13493
|
+
return emptyPRCountsResult();
|
|
13494
|
+
}),
|
|
13495
|
+
prMonitor.fetchUserClosedPRCounts().catch((err) => {
|
|
13496
|
+
console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
|
|
13497
|
+
return emptyPRCountsResult();
|
|
13498
|
+
}),
|
|
13481
13499
|
prMonitor.fetchRecentlyClosedPRs().catch((err) => {
|
|
13482
13500
|
console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
|
|
13483
13501
|
return [];
|
|
@@ -13626,12 +13644,16 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
|
|
|
13626
13644
|
function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
|
|
13627
13645
|
const stateManager2 = getStateManager();
|
|
13628
13646
|
try {
|
|
13629
|
-
|
|
13647
|
+
if (Object.keys(monthlyCounts).length > 0) {
|
|
13648
|
+
stateManager2.setMonthlyMergedCounts(monthlyCounts);
|
|
13649
|
+
}
|
|
13630
13650
|
} catch (error) {
|
|
13631
13651
|
console.error("[DAILY] Failed to store monthly merged counts:", errorMessage(error));
|
|
13632
13652
|
}
|
|
13633
13653
|
try {
|
|
13634
|
-
|
|
13654
|
+
if (Object.keys(monthlyClosedCounts).length > 0) {
|
|
13655
|
+
stateManager2.setMonthlyClosedCounts(monthlyClosedCounts);
|
|
13656
|
+
}
|
|
13635
13657
|
} catch (error) {
|
|
13636
13658
|
console.error("[DAILY] Failed to store monthly closed counts:", errorMessage(error));
|
|
13637
13659
|
}
|
|
@@ -13646,7 +13668,9 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
|
|
|
13646
13668
|
combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
|
|
13647
13669
|
}
|
|
13648
13670
|
}
|
|
13649
|
-
|
|
13671
|
+
if (Object.keys(combinedOpenedCounts).length > 0) {
|
|
13672
|
+
stateManager2.setMonthlyOpenedCounts(combinedOpenedCounts);
|
|
13673
|
+
}
|
|
13650
13674
|
} catch (error) {
|
|
13651
13675
|
console.error("[DAILY] Failed to compute/store monthly opened counts:", errorMessage(error));
|
|
13652
13676
|
}
|
|
@@ -13816,6 +13840,7 @@ var init_daily = __esm({
|
|
|
13816
13840
|
"use strict";
|
|
13817
13841
|
init_core();
|
|
13818
13842
|
init_errors();
|
|
13843
|
+
init_github_stats();
|
|
13819
13844
|
init_json();
|
|
13820
13845
|
init_core();
|
|
13821
13846
|
}
|
|
@@ -14542,8 +14567,14 @@ async function fetchDashboardData(token) {
|
|
|
14542
14567
|
console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
|
|
14543
14568
|
return [];
|
|
14544
14569
|
}),
|
|
14545
|
-
prMonitor.fetchUserMergedPRCounts()
|
|
14546
|
-
|
|
14570
|
+
prMonitor.fetchUserMergedPRCounts().catch((err) => {
|
|
14571
|
+
console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
|
|
14572
|
+
return emptyPRCountsResult();
|
|
14573
|
+
}),
|
|
14574
|
+
prMonitor.fetchUserClosedPRCounts().catch((err) => {
|
|
14575
|
+
console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
|
|
14576
|
+
return emptyPRCountsResult();
|
|
14577
|
+
}),
|
|
14547
14578
|
issueMonitor.fetchCommentedIssues().catch((error) => {
|
|
14548
14579
|
const msg = errorMessage(error);
|
|
14549
14580
|
if (msg.includes("No GitHub username configured")) {
|
|
@@ -14567,12 +14598,16 @@ async function fetchDashboardData(token) {
|
|
|
14567
14598
|
const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
|
|
14568
14599
|
const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
|
|
14569
14600
|
try {
|
|
14570
|
-
|
|
14601
|
+
if (Object.keys(monthlyCounts).length > 0) {
|
|
14602
|
+
stateManager2.setMonthlyMergedCounts(monthlyCounts);
|
|
14603
|
+
}
|
|
14571
14604
|
} catch (error) {
|
|
14572
14605
|
console.error("[DASHBOARD] Failed to store monthly merged counts:", errorMessage(error));
|
|
14573
14606
|
}
|
|
14574
14607
|
try {
|
|
14575
|
-
|
|
14608
|
+
if (Object.keys(monthlyClosedCounts).length > 0) {
|
|
14609
|
+
stateManager2.setMonthlyClosedCounts(monthlyClosedCounts);
|
|
14610
|
+
}
|
|
14576
14611
|
} catch (error) {
|
|
14577
14612
|
console.error("[DASHBOARD] Failed to store monthly closed counts:", errorMessage(error));
|
|
14578
14613
|
}
|
|
@@ -14587,7 +14622,9 @@ async function fetchDashboardData(token) {
|
|
|
14587
14622
|
combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
|
|
14588
14623
|
}
|
|
14589
14624
|
}
|
|
14590
|
-
|
|
14625
|
+
if (Object.keys(combinedOpenedCounts).length > 0) {
|
|
14626
|
+
stateManager2.setMonthlyOpenedCounts(combinedOpenedCounts);
|
|
14627
|
+
}
|
|
14591
14628
|
} catch (error) {
|
|
14592
14629
|
console.error("[DASHBOARD] Failed to store monthly opened counts:", errorMessage(error));
|
|
14593
14630
|
}
|
|
@@ -14634,6 +14671,7 @@ var init_dashboard_data = __esm({
|
|
|
14634
14671
|
"use strict";
|
|
14635
14672
|
init_core();
|
|
14636
14673
|
init_errors();
|
|
14674
|
+
init_github_stats();
|
|
14637
14675
|
init_daily();
|
|
14638
14676
|
}
|
|
14639
14677
|
});
|
|
@@ -16263,8 +16301,79 @@ var init_dashboard_templates = __esm({
|
|
|
16263
16301
|
// src/commands/dashboard-server.ts
|
|
16264
16302
|
var dashboard_server_exports = {};
|
|
16265
16303
|
__export(dashboard_server_exports, {
|
|
16266
|
-
|
|
16304
|
+
findRunningDashboardServer: () => findRunningDashboardServer,
|
|
16305
|
+
getDashboardPidPath: () => getDashboardPidPath,
|
|
16306
|
+
isDashboardServerRunning: () => isDashboardServerRunning,
|
|
16307
|
+
readDashboardServerInfo: () => readDashboardServerInfo,
|
|
16308
|
+
removeDashboardServerInfo: () => removeDashboardServerInfo,
|
|
16309
|
+
startDashboardServer: () => startDashboardServer,
|
|
16310
|
+
writeDashboardServerInfo: () => writeDashboardServerInfo
|
|
16267
16311
|
});
|
|
16312
|
+
function getDashboardPidPath() {
|
|
16313
|
+
return path5.join(getDataDir(), "dashboard-server.pid");
|
|
16314
|
+
}
|
|
16315
|
+
function writeDashboardServerInfo(info2) {
|
|
16316
|
+
fs5.writeFileSync(getDashboardPidPath(), JSON.stringify(info2), { mode: 384 });
|
|
16317
|
+
}
|
|
16318
|
+
function readDashboardServerInfo() {
|
|
16319
|
+
try {
|
|
16320
|
+
const content = fs5.readFileSync(getDashboardPidPath(), "utf-8");
|
|
16321
|
+
const parsed = JSON.parse(content);
|
|
16322
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.pid !== "number" || typeof parsed.port !== "number" || typeof parsed.startedAt !== "string") {
|
|
16323
|
+
console.error("[DASHBOARD] PID file has invalid structure, ignoring");
|
|
16324
|
+
return null;
|
|
16325
|
+
}
|
|
16326
|
+
return parsed;
|
|
16327
|
+
} catch (err) {
|
|
16328
|
+
const code = err.code;
|
|
16329
|
+
if (code !== "ENOENT") {
|
|
16330
|
+
console.error(`[DASHBOARD] Failed to read PID file: ${err.message}`);
|
|
16331
|
+
}
|
|
16332
|
+
return null;
|
|
16333
|
+
}
|
|
16334
|
+
}
|
|
16335
|
+
function removeDashboardServerInfo() {
|
|
16336
|
+
try {
|
|
16337
|
+
fs5.unlinkSync(getDashboardPidPath());
|
|
16338
|
+
} catch (err) {
|
|
16339
|
+
const code = err.code;
|
|
16340
|
+
if (code !== "ENOENT") {
|
|
16341
|
+
console.error(`[DASHBOARD] Failed to remove PID file: ${err.message}`);
|
|
16342
|
+
}
|
|
16343
|
+
}
|
|
16344
|
+
}
|
|
16345
|
+
function isDashboardServerRunning(port) {
|
|
16346
|
+
return new Promise((resolve5) => {
|
|
16347
|
+
const req = http.get(`http://127.0.0.1:${port}/api/data`, { timeout: 2e3 }, (res) => {
|
|
16348
|
+
res.resume();
|
|
16349
|
+
resolve5(res.statusCode === 200);
|
|
16350
|
+
});
|
|
16351
|
+
req.on("error", () => resolve5(false));
|
|
16352
|
+
req.on("timeout", () => {
|
|
16353
|
+
req.destroy();
|
|
16354
|
+
resolve5(false);
|
|
16355
|
+
});
|
|
16356
|
+
});
|
|
16357
|
+
}
|
|
16358
|
+
async function findRunningDashboardServer() {
|
|
16359
|
+
const info2 = readDashboardServerInfo();
|
|
16360
|
+
if (!info2) return null;
|
|
16361
|
+
try {
|
|
16362
|
+
process.kill(info2.pid, 0);
|
|
16363
|
+
} catch (err) {
|
|
16364
|
+
const code = err.code;
|
|
16365
|
+
if (code !== "ESRCH" && code !== "EPERM") {
|
|
16366
|
+
console.error(`[DASHBOARD] Unexpected error checking PID ${info2.pid}: ${err.message}`);
|
|
16367
|
+
}
|
|
16368
|
+
removeDashboardServerInfo();
|
|
16369
|
+
return null;
|
|
16370
|
+
}
|
|
16371
|
+
if (await isDashboardServerRunning(info2.port)) {
|
|
16372
|
+
return { port: info2.port, url: `http://localhost:${info2.port}` };
|
|
16373
|
+
}
|
|
16374
|
+
removeDashboardServerInfo();
|
|
16375
|
+
return null;
|
|
16376
|
+
}
|
|
16268
16377
|
function buildDashboardJson(digest, state, commentedIssues) {
|
|
16269
16378
|
const prsByRepo = computePRsByRepo(digest, state);
|
|
16270
16379
|
const topRepos = computeTopRepos(prsByRepo);
|
|
@@ -16545,6 +16654,7 @@ async function startDashboardServer(options) {
|
|
|
16545
16654
|
process.exit(1);
|
|
16546
16655
|
}
|
|
16547
16656
|
}
|
|
16657
|
+
writeDashboardServerInfo({ pid: process.pid, port: actualPort, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
16548
16658
|
const serverUrl = `http://localhost:${actualPort}`;
|
|
16549
16659
|
console.error(`Dashboard server running at ${serverUrl}`);
|
|
16550
16660
|
if (open) {
|
|
@@ -16574,6 +16684,7 @@ async function startDashboardServer(options) {
|
|
|
16574
16684
|
}
|
|
16575
16685
|
const shutdown = () => {
|
|
16576
16686
|
console.error("\nShutting down dashboard server...");
|
|
16687
|
+
removeDashboardServerInfo();
|
|
16577
16688
|
server.close(() => {
|
|
16578
16689
|
process.exit(0);
|
|
16579
16690
|
});
|
|
@@ -16611,6 +16722,7 @@ var init_dashboard_server = __esm({
|
|
|
16611
16722
|
// src/commands/dashboard.ts
|
|
16612
16723
|
var dashboard_exports = {};
|
|
16613
16724
|
__export(dashboard_exports, {
|
|
16725
|
+
resolveAssetsDir: () => resolveAssetsDir,
|
|
16614
16726
|
runDashboard: () => runDashboard,
|
|
16615
16727
|
serveDashboard: () => serveDashboard,
|
|
16616
16728
|
writeDashboardFromState: () => writeDashboardFromState
|
|
@@ -17138,6 +17250,78 @@ var init_local_repos = __esm({
|
|
|
17138
17250
|
}
|
|
17139
17251
|
});
|
|
17140
17252
|
|
|
17253
|
+
// src/commands/dashboard-lifecycle.ts
|
|
17254
|
+
function sleep(ms) {
|
|
17255
|
+
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
17256
|
+
}
|
|
17257
|
+
async function launchDashboardServer(options) {
|
|
17258
|
+
const assetsDir = resolveAssetsDir();
|
|
17259
|
+
if (!assetsDir) {
|
|
17260
|
+
return null;
|
|
17261
|
+
}
|
|
17262
|
+
const existing = await findRunningDashboardServer();
|
|
17263
|
+
if (existing) {
|
|
17264
|
+
return { url: existing.url, port: existing.port, alreadyRunning: true };
|
|
17265
|
+
}
|
|
17266
|
+
const port = options?.port ?? DEFAULT_PORT;
|
|
17267
|
+
const cliPath = process.argv[1];
|
|
17268
|
+
const child = (0, import_child_process5.spawn)("node", [cliPath, "dashboard", "serve", "--port", String(port), "--no-open"], {
|
|
17269
|
+
detached: true,
|
|
17270
|
+
stdio: "ignore"
|
|
17271
|
+
});
|
|
17272
|
+
let spawnFailed = false;
|
|
17273
|
+
let childExited = false;
|
|
17274
|
+
child.on("error", (err) => {
|
|
17275
|
+
spawnFailed = true;
|
|
17276
|
+
console.error(`[STARTUP] Failed to spawn dashboard server: ${err.message}`);
|
|
17277
|
+
});
|
|
17278
|
+
child.on("exit", (code) => {
|
|
17279
|
+
childExited = true;
|
|
17280
|
+
if (code !== 0 && code !== null) {
|
|
17281
|
+
console.error(`[STARTUP] Dashboard server exited prematurely (code ${code})`);
|
|
17282
|
+
}
|
|
17283
|
+
});
|
|
17284
|
+
child.unref();
|
|
17285
|
+
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
|
17286
|
+
await sleep(POLL_INTERVAL_MS);
|
|
17287
|
+
if (spawnFailed || childExited) {
|
|
17288
|
+
return null;
|
|
17289
|
+
}
|
|
17290
|
+
const info2 = readDashboardServerInfo();
|
|
17291
|
+
if (info2) {
|
|
17292
|
+
if (await isDashboardServerRunning(info2.port)) {
|
|
17293
|
+
return { url: `http://localhost:${info2.port}`, port: info2.port, alreadyRunning: false };
|
|
17294
|
+
}
|
|
17295
|
+
}
|
|
17296
|
+
}
|
|
17297
|
+
console.error("[STARTUP] Dashboard server failed to start within 5 seconds");
|
|
17298
|
+
if (child.pid) {
|
|
17299
|
+
try {
|
|
17300
|
+
process.kill(child.pid, "SIGTERM");
|
|
17301
|
+
} catch (err) {
|
|
17302
|
+
const code = err.code;
|
|
17303
|
+
if (code !== "ESRCH") {
|
|
17304
|
+
console.error(
|
|
17305
|
+
`[STARTUP] Failed to kill orphan dashboard process (PID ${child.pid}): ${err.message}`
|
|
17306
|
+
);
|
|
17307
|
+
}
|
|
17308
|
+
}
|
|
17309
|
+
}
|
|
17310
|
+
return null;
|
|
17311
|
+
}
|
|
17312
|
+
var import_child_process5, DEFAULT_PORT, POLL_INTERVAL_MS, MAX_POLL_ATTEMPTS;
|
|
17313
|
+
var init_dashboard_lifecycle = __esm({
|
|
17314
|
+
"src/commands/dashboard-lifecycle.ts"() {
|
|
17315
|
+
"use strict";
|
|
17316
|
+
import_child_process5 = require("child_process");
|
|
17317
|
+
init_dashboard_server();
|
|
17318
|
+
init_dashboard();
|
|
17319
|
+
DEFAULT_PORT = 3e3;
|
|
17320
|
+
POLL_INTERVAL_MS = 200;
|
|
17321
|
+
MAX_POLL_ATTEMPTS = 25;
|
|
17322
|
+
}
|
|
17323
|
+
});
|
|
17324
|
+
|
|
17141
17325
|
// src/commands/startup.ts
|
|
17142
17326
|
var startup_exports = {};
|
|
17143
17327
|
__export(startup_exports, {
|
|
@@ -17205,10 +17389,23 @@ function detectIssueList() {
|
|
|
17205
17389
|
}
|
|
17206
17390
|
}
|
|
17207
17391
|
function openInBrowser(filePath) {
|
|
17208
|
-
|
|
17209
|
-
|
|
17210
|
-
|
|
17211
|
-
|
|
17392
|
+
let openCmd;
|
|
17393
|
+
let args;
|
|
17394
|
+
switch (process.platform) {
|
|
17395
|
+
case "darwin":
|
|
17396
|
+
openCmd = "open";
|
|
17397
|
+
args = [filePath];
|
|
17398
|
+
break;
|
|
17399
|
+
case "win32":
|
|
17400
|
+
openCmd = "cmd";
|
|
17401
|
+
args = ["/c", "start", "", filePath];
|
|
17402
|
+
break;
|
|
17403
|
+
default:
|
|
17404
|
+
openCmd = "xdg-open";
|
|
17405
|
+
args = [filePath];
|
|
17406
|
+
break;
|
|
17407
|
+
}
|
|
17408
|
+
(0, import_child_process6.execFile)(openCmd, args, (error) => {
|
|
17212
17409
|
if (error) {
|
|
17213
17410
|
console.error(`[STARTUP] Failed to open dashboard in browser: ${error.message}`);
|
|
17214
17411
|
}
|
|
@@ -17230,15 +17427,28 @@ async function runStartup() {
|
|
|
17230
17427
|
}
|
|
17231
17428
|
const daily = await executeDailyCheck(token);
|
|
17232
17429
|
let dashboardPath;
|
|
17233
|
-
let dashboardOpened = false;
|
|
17234
17430
|
try {
|
|
17235
17431
|
dashboardPath = writeDashboardFromState();
|
|
17236
|
-
|
|
17432
|
+
} catch (error) {
|
|
17433
|
+
console.error("[STARTUP] Dashboard generation failed:", errorMessage(error));
|
|
17434
|
+
}
|
|
17435
|
+
let dashboardUrl;
|
|
17436
|
+
let dashboardOpened = false;
|
|
17437
|
+
if (daily.digest.summary.totalActivePRs > 0) {
|
|
17438
|
+
let spaResult = null;
|
|
17439
|
+
try {
|
|
17440
|
+
spaResult = await launchDashboardServer();
|
|
17441
|
+
} catch (error) {
|
|
17442
|
+
console.error("[STARTUP] SPA dashboard launch failed:", errorMessage(error));
|
|
17443
|
+
}
|
|
17444
|
+
if (spaResult) {
|
|
17445
|
+
dashboardUrl = spaResult.url;
|
|
17446
|
+
openInBrowser(spaResult.url);
|
|
17447
|
+
dashboardOpened = true;
|
|
17448
|
+
} else if (dashboardPath) {
|
|
17237
17449
|
openInBrowser(dashboardPath);
|
|
17238
17450
|
dashboardOpened = true;
|
|
17239
17451
|
}
|
|
17240
|
-
} catch (error) {
|
|
17241
|
-
console.error("[STARTUP] Dashboard generation failed:", errorMessage(error));
|
|
17242
17452
|
}
|
|
17243
17453
|
if (dashboardOpened) {
|
|
17244
17454
|
daily.briefSummary += " | Dashboard opened in browser";
|
|
@@ -17249,19 +17459,21 @@ async function runStartup() {
|
|
|
17249
17459
|
setupComplete: true,
|
|
17250
17460
|
daily,
|
|
17251
17461
|
dashboardPath,
|
|
17462
|
+
dashboardUrl,
|
|
17252
17463
|
issueList
|
|
17253
17464
|
};
|
|
17254
17465
|
}
|
|
17255
|
-
var fs9,
|
|
17466
|
+
var fs9, import_child_process6;
|
|
17256
17467
|
var init_startup = __esm({
|
|
17257
17468
|
"src/commands/startup.ts"() {
|
|
17258
17469
|
"use strict";
|
|
17259
17470
|
fs9 = __toESM(require("fs"), 1);
|
|
17260
|
-
|
|
17471
|
+
import_child_process6 = require("child_process");
|
|
17261
17472
|
init_core();
|
|
17262
17473
|
init_errors();
|
|
17263
17474
|
init_daily();
|
|
17264
17475
|
init_dashboard();
|
|
17476
|
+
init_dashboard_lifecycle();
|
|
17265
17477
|
}
|
|
17266
17478
|
});
|
|
17267
17479
|
|
package/dist/commands/daily.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
|
|
10
10
|
import { errorMessage } from '../core/errors.js';
|
|
11
|
+
import { emptyPRCountsResult } from '../core/github-stats.js';
|
|
11
12
|
import { deduplicateDigest, compactActionableIssues, compactRepoGroups, } from '../formatters/json.js';
|
|
12
13
|
// Re-export domain functions so existing consumers (tests, dashboard, startup)
|
|
13
14
|
// can continue importing from './daily.js' without changes.
|
|
@@ -28,11 +29,17 @@ async function fetchPRData(prMonitor, token) {
|
|
|
28
29
|
console.error(`Warning: ${failures.length} PR fetch(es) failed`);
|
|
29
30
|
}
|
|
30
31
|
// Fetch merged PR counts, closed PR counts, recently closed PRs, recently merged PRs, and commented issues in parallel
|
|
31
|
-
//
|
|
32
|
+
// All stats fetches are non-critical (cosmetic/scoring), so isolate their failure
|
|
32
33
|
const issueMonitor = new IssueConversationMonitor(token);
|
|
33
34
|
const [mergedResult, closedResult, recentlyClosedPRs, recentlyMergedPRs, issueConversationResult] = await Promise.all([
|
|
34
|
-
prMonitor.fetchUserMergedPRCounts()
|
|
35
|
-
|
|
35
|
+
prMonitor.fetchUserMergedPRCounts().catch((err) => {
|
|
36
|
+
console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
|
|
37
|
+
return emptyPRCountsResult();
|
|
38
|
+
}),
|
|
39
|
+
prMonitor.fetchUserClosedPRCounts().catch((err) => {
|
|
40
|
+
console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
|
|
41
|
+
return emptyPRCountsResult();
|
|
42
|
+
}),
|
|
36
43
|
prMonitor.fetchRecentlyClosedPRs().catch((err) => {
|
|
37
44
|
console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
|
|
38
45
|
return [];
|
|
@@ -195,15 +202,21 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
|
|
|
195
202
|
*/
|
|
196
203
|
function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
|
|
197
204
|
const stateManager = getStateManager();
|
|
198
|
-
// Store monthly chart data (non-critical — each metric isolated so partial failures don't leave inconsistent state)
|
|
205
|
+
// Store monthly chart data (non-critical — each metric isolated so partial failures don't leave inconsistent state).
|
|
206
|
+
// Guard: skip overwriting when the data is empty to avoid wiping existing chart data on transient API failures.
|
|
207
|
+
// An empty object means the fetch failed and fell back to emptyPRCountsResult(), so we preserve previous state.
|
|
199
208
|
try {
|
|
200
|
-
|
|
209
|
+
if (Object.keys(monthlyCounts).length > 0) {
|
|
210
|
+
stateManager.setMonthlyMergedCounts(monthlyCounts);
|
|
211
|
+
}
|
|
201
212
|
}
|
|
202
213
|
catch (error) {
|
|
203
214
|
console.error('[DAILY] Failed to store monthly merged counts:', errorMessage(error));
|
|
204
215
|
}
|
|
205
216
|
try {
|
|
206
|
-
|
|
217
|
+
if (Object.keys(monthlyClosedCounts).length > 0) {
|
|
218
|
+
stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
|
|
219
|
+
}
|
|
207
220
|
}
|
|
208
221
|
catch (error) {
|
|
209
222
|
console.error('[DAILY] Failed to store monthly closed counts:', errorMessage(error));
|
|
@@ -221,7 +234,9 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
|
|
|
221
234
|
combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
|
|
222
235
|
}
|
|
223
236
|
}
|
|
224
|
-
|
|
237
|
+
if (Object.keys(combinedOpenedCounts).length > 0) {
|
|
238
|
+
stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
|
|
239
|
+
}
|
|
225
240
|
}
|
|
226
241
|
catch (error) {
|
|
227
242
|
console.error('[DAILY] Failed to compute/store monthly opened counts:', errorMessage(error));
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getStateManager, PRMonitor, IssueConversationMonitor } from '../core/index.js';
|
|
7
7
|
import { errorMessage } from '../core/errors.js';
|
|
8
|
+
import { emptyPRCountsResult } from '../core/github-stats.js';
|
|
8
9
|
import { toShelvedPRRef } from './daily.js';
|
|
9
10
|
/**
|
|
10
11
|
* Fetch fresh dashboard data from GitHub.
|
|
@@ -25,8 +26,14 @@ export async function fetchDashboardData(token) {
|
|
|
25
26
|
console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
|
|
26
27
|
return [];
|
|
27
28
|
}),
|
|
28
|
-
prMonitor.fetchUserMergedPRCounts()
|
|
29
|
-
|
|
29
|
+
prMonitor.fetchUserMergedPRCounts().catch((err) => {
|
|
30
|
+
console.error(`Warning: Failed to fetch merged PR counts: ${errorMessage(err)}`);
|
|
31
|
+
return emptyPRCountsResult();
|
|
32
|
+
}),
|
|
33
|
+
prMonitor.fetchUserClosedPRCounts().catch((err) => {
|
|
34
|
+
console.error(`Warning: Failed to fetch closed PR counts: ${errorMessage(err)}`);
|
|
35
|
+
return emptyPRCountsResult();
|
|
36
|
+
}),
|
|
30
37
|
issueMonitor.fetchCommentedIssues().catch((error) => {
|
|
31
38
|
const msg = errorMessage(error);
|
|
32
39
|
if (msg.includes('No GitHub username configured')) {
|
|
@@ -51,14 +58,19 @@ export async function fetchDashboardData(token) {
|
|
|
51
58
|
// Store monthly chart data (opened/merged/closed) so charts have data
|
|
52
59
|
const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
|
|
53
60
|
const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
|
|
61
|
+
// Guard: skip overwriting when data is empty to avoid wiping chart data on transient API failures.
|
|
54
62
|
try {
|
|
55
|
-
|
|
63
|
+
if (Object.keys(monthlyCounts).length > 0) {
|
|
64
|
+
stateManager.setMonthlyMergedCounts(monthlyCounts);
|
|
65
|
+
}
|
|
56
66
|
}
|
|
57
67
|
catch (error) {
|
|
58
68
|
console.error('[DASHBOARD] Failed to store monthly merged counts:', errorMessage(error));
|
|
59
69
|
}
|
|
60
70
|
try {
|
|
61
|
-
|
|
71
|
+
if (Object.keys(monthlyClosedCounts).length > 0) {
|
|
72
|
+
stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
|
|
73
|
+
}
|
|
62
74
|
}
|
|
63
75
|
catch (error) {
|
|
64
76
|
console.error('[DASHBOARD] Failed to store monthly closed counts:', errorMessage(error));
|
|
@@ -74,7 +86,9 @@ export async function fetchDashboardData(token) {
|
|
|
74
86
|
combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
|
-
|
|
89
|
+
if (Object.keys(combinedOpenedCounts).length > 0) {
|
|
90
|
+
stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
|
|
91
|
+
}
|
|
78
92
|
}
|
|
79
93
|
catch (error) {
|
|
80
94
|
console.error('[DASHBOARD] Failed to store monthly opened counts:', errorMessage(error));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard server lifecycle management.
|
|
3
|
+
* Handles launching the interactive SPA dashboard as a background process
|
|
4
|
+
* and detecting whether a server is already running.
|
|
5
|
+
*/
|
|
6
|
+
export interface LaunchResult {
|
|
7
|
+
url: string;
|
|
8
|
+
port: number;
|
|
9
|
+
alreadyRunning: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Launch the interactive dashboard SPA server as a detached background process.
|
|
13
|
+
*
|
|
14
|
+
* Returns the server URL if launched successfully, or null if the SPA assets
|
|
15
|
+
* are not available (caller should fall back to static HTML).
|
|
16
|
+
*
|
|
17
|
+
* If a server is already running (detected via PID file + health probe),
|
|
18
|
+
* returns its URL without launching a new one.
|
|
19
|
+
*/
|
|
20
|
+
export declare function launchDashboardServer(options?: {
|
|
21
|
+
port?: number;
|
|
22
|
+
}): Promise<LaunchResult | null>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard server lifecycle management.
|
|
3
|
+
* Handles launching the interactive SPA dashboard as a background process
|
|
4
|
+
* and detecting whether a server is already running.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { findRunningDashboardServer, isDashboardServerRunning, readDashboardServerInfo } from './dashboard-server.js';
|
|
8
|
+
import { resolveAssetsDir } from './dashboard.js';
|
|
9
|
+
const DEFAULT_PORT = 3000;
|
|
10
|
+
const POLL_INTERVAL_MS = 200;
|
|
11
|
+
const MAX_POLL_ATTEMPTS = 25; // 5 seconds total
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Launch the interactive dashboard SPA server as a detached background process.
|
|
17
|
+
*
|
|
18
|
+
* Returns the server URL if launched successfully, or null if the SPA assets
|
|
19
|
+
* are not available (caller should fall back to static HTML).
|
|
20
|
+
*
|
|
21
|
+
* If a server is already running (detected via PID file + health probe),
|
|
22
|
+
* returns its URL without launching a new one.
|
|
23
|
+
*/
|
|
24
|
+
export async function launchDashboardServer(options) {
|
|
25
|
+
// 1. Check if SPA assets exist
|
|
26
|
+
const assetsDir = resolveAssetsDir();
|
|
27
|
+
if (!assetsDir) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// 2. Check if a server is already running
|
|
31
|
+
const existing = await findRunningDashboardServer();
|
|
32
|
+
if (existing) {
|
|
33
|
+
return { url: existing.url, port: existing.port, alreadyRunning: true };
|
|
34
|
+
}
|
|
35
|
+
// 3. Launch as detached child process
|
|
36
|
+
const port = options?.port ?? DEFAULT_PORT;
|
|
37
|
+
// process.argv[1] is the CLI entry point (cli.bundle.cjs in production, cli.ts in dev)
|
|
38
|
+
const cliPath = process.argv[1];
|
|
39
|
+
const child = spawn('node', [cliPath, 'dashboard', 'serve', '--port', String(port), '--no-open'], {
|
|
40
|
+
detached: true,
|
|
41
|
+
stdio: 'ignore',
|
|
42
|
+
});
|
|
43
|
+
// Track spawn failures and early exits so the polling loop can bail early
|
|
44
|
+
let spawnFailed = false;
|
|
45
|
+
let childExited = false;
|
|
46
|
+
child.on('error', (err) => {
|
|
47
|
+
spawnFailed = true;
|
|
48
|
+
console.error(`[STARTUP] Failed to spawn dashboard server: ${err.message}`);
|
|
49
|
+
});
|
|
50
|
+
child.on('exit', (code) => {
|
|
51
|
+
childExited = true;
|
|
52
|
+
if (code !== 0 && code !== null) {
|
|
53
|
+
console.error(`[STARTUP] Dashboard server exited prematurely (code ${code})`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
child.unref();
|
|
57
|
+
// 4. Poll for PID file to appear (server writes it after binding)
|
|
58
|
+
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
|
59
|
+
await sleep(POLL_INTERVAL_MS);
|
|
60
|
+
// Bail early if spawn failed or child crashed before writing PID file
|
|
61
|
+
if (spawnFailed || childExited) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const info = readDashboardServerInfo();
|
|
65
|
+
if (info) {
|
|
66
|
+
// PID file appeared — verify the server is responding
|
|
67
|
+
if (await isDashboardServerRunning(info.port)) {
|
|
68
|
+
return { url: `http://localhost:${info.port}`, port: info.port, alreadyRunning: false };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 5. Timeout — server didn't start in time; kill orphan process
|
|
73
|
+
console.error('[STARTUP] Dashboard server failed to start within 5 seconds');
|
|
74
|
+
if (child.pid) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(child.pid, 'SIGTERM');
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// ESRCH = process already exited, which is fine
|
|
80
|
+
const code = err.code;
|
|
81
|
+
if (code !== 'ESRCH') {
|
|
82
|
+
console.error(`[STARTUP] Failed to kill orphan dashboard process (PID ${child.pid}): ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
@@ -11,4 +11,18 @@ export interface DashboardServerOptions {
|
|
|
11
11
|
token: string | null;
|
|
12
12
|
open: boolean;
|
|
13
13
|
}
|
|
14
|
+
export interface DashboardServerInfo {
|
|
15
|
+
pid: number;
|
|
16
|
+
port: number;
|
|
17
|
+
startedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function getDashboardPidPath(): string;
|
|
20
|
+
export declare function writeDashboardServerInfo(info: DashboardServerInfo): void;
|
|
21
|
+
export declare function readDashboardServerInfo(): DashboardServerInfo | null;
|
|
22
|
+
export declare function removeDashboardServerInfo(): void;
|
|
23
|
+
export declare function isDashboardServerRunning(port: number): Promise<boolean>;
|
|
24
|
+
export declare function findRunningDashboardServer(): Promise<{
|
|
25
|
+
port: number;
|
|
26
|
+
url: string;
|
|
27
|
+
} | null>;
|
|
14
28
|
export declare function startDashboardServer(options: DashboardServerOptions): Promise<void>;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import * as http from 'http';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { getStateManager, getGitHubToken } from '../core/index.js';
|
|
11
|
+
import { getStateManager, getGitHubToken, getDataDir } from '../core/index.js';
|
|
12
12
|
import { errorMessage, ValidationError } from '../core/errors.js';
|
|
13
13
|
import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN } from './validation.js';
|
|
14
14
|
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
|
|
@@ -25,6 +25,87 @@ const MIME_TYPES = {
|
|
|
25
25
|
'.png': 'image/png',
|
|
26
26
|
'.ico': 'image/x-icon',
|
|
27
27
|
};
|
|
28
|
+
// ── PID File Management ──────────────────────────────────────────────────────
|
|
29
|
+
export function getDashboardPidPath() {
|
|
30
|
+
return path.join(getDataDir(), 'dashboard-server.pid');
|
|
31
|
+
}
|
|
32
|
+
export function writeDashboardServerInfo(info) {
|
|
33
|
+
fs.writeFileSync(getDashboardPidPath(), JSON.stringify(info), { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
export function readDashboardServerInfo() {
|
|
36
|
+
try {
|
|
37
|
+
const content = fs.readFileSync(getDashboardPidPath(), 'utf-8');
|
|
38
|
+
const parsed = JSON.parse(content);
|
|
39
|
+
if (typeof parsed !== 'object' ||
|
|
40
|
+
parsed === null ||
|
|
41
|
+
typeof parsed.pid !== 'number' ||
|
|
42
|
+
typeof parsed.port !== 'number' ||
|
|
43
|
+
typeof parsed.startedAt !== 'string') {
|
|
44
|
+
console.error('[DASHBOARD] PID file has invalid structure, ignoring');
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const code = err.code;
|
|
51
|
+
if (code !== 'ENOENT') {
|
|
52
|
+
console.error(`[DASHBOARD] Failed to read PID file: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function removeDashboardServerInfo() {
|
|
58
|
+
try {
|
|
59
|
+
fs.unlinkSync(getDashboardPidPath());
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const code = err.code;
|
|
63
|
+
if (code !== 'ENOENT') {
|
|
64
|
+
console.error(`[DASHBOARD] Failed to remove PID file: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Health Probe ─────────────────────────────────────────────────────────────
|
|
69
|
+
export function isDashboardServerRunning(port) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const req = http.get(`http://127.0.0.1:${port}/api/data`, { timeout: 2000 }, (res) => {
|
|
72
|
+
// Consume response data to free up memory
|
|
73
|
+
res.resume();
|
|
74
|
+
resolve(res.statusCode === 200);
|
|
75
|
+
});
|
|
76
|
+
req.on('error', () => resolve(false));
|
|
77
|
+
req.on('timeout', () => {
|
|
78
|
+
req.destroy();
|
|
79
|
+
resolve(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export async function findRunningDashboardServer() {
|
|
84
|
+
const info = readDashboardServerInfo();
|
|
85
|
+
if (!info)
|
|
86
|
+
return null;
|
|
87
|
+
// Check if process is alive (signal 0 = existence check only)
|
|
88
|
+
try {
|
|
89
|
+
process.kill(info.pid, 0);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const code = err.code;
|
|
93
|
+
if (code !== 'ESRCH' && code !== 'EPERM') {
|
|
94
|
+
console.error(`[DASHBOARD] Unexpected error checking PID ${info.pid}: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
// ESRCH = no process at that PID; EPERM = PID recycled to another user's process
|
|
97
|
+
// Either way, our dashboard server is no longer running — clean up stale PID file
|
|
98
|
+
removeDashboardServerInfo();
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
// Process exists — verify it's actually our server via HTTP probe
|
|
102
|
+
if (await isDashboardServerRunning(info.port)) {
|
|
103
|
+
return { port: info.port, url: `http://localhost:${info.port}` };
|
|
104
|
+
}
|
|
105
|
+
// Process exists but not responding on expected port — stale
|
|
106
|
+
removeDashboardServerInfo();
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
28
109
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
29
110
|
/**
|
|
30
111
|
* Build the JSON payload that the SPA expects from GET /api/data.
|
|
@@ -359,6 +440,8 @@ export async function startDashboardServer(options) {
|
|
|
359
440
|
process.exit(1);
|
|
360
441
|
}
|
|
361
442
|
}
|
|
443
|
+
// Write PID file so other processes can detect this running server
|
|
444
|
+
writeDashboardServerInfo({ pid: process.pid, port: actualPort, startedAt: new Date().toISOString() });
|
|
362
445
|
const serverUrl = `http://localhost:${actualPort}`;
|
|
363
446
|
console.error(`Dashboard server running at ${serverUrl}`);
|
|
364
447
|
// ── Open browser ─────────────────────────────────────────────────────────
|
|
@@ -390,6 +473,7 @@ export async function startDashboardServer(options) {
|
|
|
390
473
|
// ── Clean shutdown ───────────────────────────────────────────────────────
|
|
391
474
|
const shutdown = () => {
|
|
392
475
|
console.error('\nShutting down dashboard server...');
|
|
476
|
+
removeDashboardServerInfo();
|
|
393
477
|
server.close(() => {
|
|
394
478
|
process.exit(0);
|
|
395
479
|
});
|
|
@@ -19,5 +19,10 @@ interface ServeOptions {
|
|
|
19
19
|
port: number;
|
|
20
20
|
open: boolean;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the SPA assets directory from packages/dashboard/dist/.
|
|
24
|
+
* Tries multiple strategies to locate it across dev (tsx) and bundled (cjs) modes.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveAssetsDir(): string | null;
|
|
22
27
|
export declare function serveDashboard(options: ServeOptions): Promise<void>;
|
|
23
28
|
export {};
|
|
@@ -138,7 +138,7 @@ export function writeDashboardFromState() {
|
|
|
138
138
|
* Resolve the SPA assets directory from packages/dashboard/dist/.
|
|
139
139
|
* Tries multiple strategies to locate it across dev (tsx) and bundled (cjs) modes.
|
|
140
140
|
*/
|
|
141
|
-
function resolveAssetsDir() {
|
|
141
|
+
export function resolveAssetsDir() {
|
|
142
142
|
// Strategy 1: relative to this source file (works in dev with tsx)
|
|
143
143
|
const devPath = path.resolve(__dirname, '../../dashboard/dist');
|
|
144
144
|
if (fs.existsSync(path.join(devPath, 'index.html'))) {
|
|
@@ -31,7 +31,7 @@ export declare function detectIssueList(): IssueListInfo | undefined;
|
|
|
31
31
|
* Returns StartupOutput with one of three shapes:
|
|
32
32
|
* 1. Setup incomplete: { version, setupComplete: false }
|
|
33
33
|
* 2. Auth failure: { version, setupComplete: true, authError: "..." }
|
|
34
|
-
* 3. Success: { version, setupComplete: true, daily, dashboardPath?, issueList? }
|
|
34
|
+
* 3. Success: { version, setupComplete: true, daily, dashboardPath?, dashboardUrl?, issueList? }
|
|
35
35
|
*
|
|
36
36
|
* Errors from the daily check propagate to the caller.
|
|
37
37
|
*/
|
package/dist/commands/startup.js
CHANGED
|
@@ -12,6 +12,7 @@ import { getStateManager, getGitHubToken, getCLIVersion } from '../core/index.js
|
|
|
12
12
|
import { errorMessage } from '../core/errors.js';
|
|
13
13
|
import { executeDailyCheck } from './daily.js';
|
|
14
14
|
import { writeDashboardFromState } from './dashboard.js';
|
|
15
|
+
import { launchDashboardServer } from './dashboard-lifecycle.js';
|
|
15
16
|
/**
|
|
16
17
|
* Parse issueListPath from a config file's YAML frontmatter.
|
|
17
18
|
* Returns the path string or undefined if not found.
|
|
@@ -93,9 +94,22 @@ export function detectIssueList() {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
function openInBrowser(filePath) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
let openCmd;
|
|
98
|
+
let args;
|
|
99
|
+
switch (process.platform) {
|
|
100
|
+
case 'darwin':
|
|
101
|
+
openCmd = 'open';
|
|
102
|
+
args = [filePath];
|
|
103
|
+
break;
|
|
104
|
+
case 'win32':
|
|
105
|
+
openCmd = 'cmd';
|
|
106
|
+
args = ['/c', 'start', '', filePath];
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
openCmd = 'xdg-open';
|
|
110
|
+
args = [filePath];
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
99
113
|
execFile(openCmd, args, (error) => {
|
|
100
114
|
if (error) {
|
|
101
115
|
console.error(`[STARTUP] Failed to open dashboard in browser: ${error.message}`);
|
|
@@ -107,7 +121,7 @@ function openInBrowser(filePath) {
|
|
|
107
121
|
* Returns StartupOutput with one of three shapes:
|
|
108
122
|
* 1. Setup incomplete: { version, setupComplete: false }
|
|
109
123
|
* 2. Auth failure: { version, setupComplete: true, authError: "..." }
|
|
110
|
-
* 3. Success: { version, setupComplete: true, daily, dashboardPath?, issueList? }
|
|
124
|
+
* 3. Success: { version, setupComplete: true, daily, dashboardPath?, dashboardUrl?, issueList? }
|
|
111
125
|
*
|
|
112
126
|
* Errors from the daily check propagate to the caller.
|
|
113
127
|
*/
|
|
@@ -129,31 +143,49 @@ export async function runStartup() {
|
|
|
129
143
|
}
|
|
130
144
|
// 3. Run daily check
|
|
131
145
|
const daily = await executeDailyCheck(token);
|
|
132
|
-
// 4. Generate
|
|
133
|
-
// Skip opening on first run (0 PRs) — the welcome flow handles onboarding
|
|
146
|
+
// 4. Generate static HTML dashboard (always — serves as fallback + snapshot)
|
|
134
147
|
let dashboardPath;
|
|
135
|
-
let dashboardOpened = false;
|
|
136
148
|
try {
|
|
137
149
|
dashboardPath = writeDashboardFromState();
|
|
138
|
-
if (daily.digest.summary.totalActivePRs > 0) {
|
|
139
|
-
openInBrowser(dashboardPath);
|
|
140
|
-
dashboardOpened = true;
|
|
141
|
-
}
|
|
142
150
|
}
|
|
143
151
|
catch (error) {
|
|
144
152
|
console.error('[STARTUP] Dashboard generation failed:', errorMessage(error));
|
|
145
153
|
}
|
|
154
|
+
// 5. Launch interactive SPA dashboard (preferred) with static HTML fallback
|
|
155
|
+
// Skip opening on first run (0 PRs) — the welcome flow handles onboarding
|
|
156
|
+
let dashboardUrl;
|
|
157
|
+
let dashboardOpened = false;
|
|
158
|
+
if (daily.digest.summary.totalActivePRs > 0) {
|
|
159
|
+
let spaResult = null;
|
|
160
|
+
try {
|
|
161
|
+
spaResult = await launchDashboardServer();
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.error('[STARTUP] SPA dashboard launch failed:', errorMessage(error));
|
|
165
|
+
}
|
|
166
|
+
if (spaResult) {
|
|
167
|
+
dashboardUrl = spaResult.url;
|
|
168
|
+
openInBrowser(spaResult.url);
|
|
169
|
+
dashboardOpened = true;
|
|
170
|
+
}
|
|
171
|
+
else if (dashboardPath) {
|
|
172
|
+
// SPA unavailable (assets not built) — fall back to static HTML
|
|
173
|
+
openInBrowser(dashboardPath);
|
|
174
|
+
dashboardOpened = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
146
177
|
// Append dashboard status to brief summary (only startup opens the browser, not daily)
|
|
147
178
|
if (dashboardOpened) {
|
|
148
179
|
daily.briefSummary += ' | Dashboard opened in browser';
|
|
149
180
|
}
|
|
150
|
-
//
|
|
181
|
+
// 6. Detect issue list
|
|
151
182
|
const issueList = detectIssueList();
|
|
152
183
|
return {
|
|
153
184
|
version,
|
|
154
185
|
setupComplete: true,
|
|
155
186
|
daily,
|
|
156
187
|
dashboardPath,
|
|
188
|
+
dashboardUrl,
|
|
157
189
|
issueList,
|
|
158
190
|
};
|
|
159
191
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Octokit } from '@octokit/rest';
|
|
6
6
|
import { ClosedPR, MergedPR } from './types.js';
|
|
7
|
-
/** TTL for cached PR count results (
|
|
7
|
+
/** TTL for cached PR count results (24 hours — these stats change slowly). */
|
|
8
8
|
export declare const PR_COUNTS_CACHE_TTL_MS: number;
|
|
9
9
|
/** Return type shared by both merged and closed PR count functions. */
|
|
10
10
|
export interface PRCountsResult<R> {
|
|
@@ -13,6 +13,8 @@ export interface PRCountsResult<R> {
|
|
|
13
13
|
monthlyOpenedCounts: Record<string, number>;
|
|
14
14
|
dailyActivityCounts: Record<string, number>;
|
|
15
15
|
}
|
|
16
|
+
/** Returns an empty PRCountsResult. Used as the fallback when stats fetches fail or username is empty. */
|
|
17
|
+
export declare function emptyPRCountsResult<R>(): PRCountsResult<R>;
|
|
16
18
|
/**
|
|
17
19
|
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
18
20
|
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
@@ -6,8 +6,18 @@ import { extractOwnerRepo, parseGitHubUrl, isOwnRepo } from './utils.js';
|
|
|
6
6
|
import { debug, warn } from './logger.js';
|
|
7
7
|
import { getHttpCache } from './http-cache.js';
|
|
8
8
|
const MODULE = 'github-stats';
|
|
9
|
-
/** TTL for cached PR count results (
|
|
10
|
-
export const PR_COUNTS_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
9
|
+
/** TTL for cached PR count results (24 hours — these stats change slowly). */
|
|
10
|
+
export const PR_COUNTS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
/**
|
|
12
|
+
* Maximum number of pages to fetch during paginated PR count searches.
|
|
13
|
+
* GitHub's Search API becomes unreliable (500s, ECONNRESET) at deep pages (4-5+).
|
|
14
|
+
* 3 pages × 100 per_page = 300 results, which covers the vast majority of users.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_PAGINATION_PAGES = 3;
|
|
17
|
+
/** Returns an empty PRCountsResult. Used as the fallback when stats fetches fail or username is empty. */
|
|
18
|
+
export function emptyPRCountsResult() {
|
|
19
|
+
return { repos: new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
|
|
20
|
+
}
|
|
11
21
|
/** Type guard for deserialized cache data — prevents crashes on corrupt/stale cache. */
|
|
12
22
|
function isCachedPRCounts(v) {
|
|
13
23
|
if (typeof v !== 'object' || v === null)
|
|
@@ -31,7 +41,7 @@ function isCachedPRCounts(v) {
|
|
|
31
41
|
*/
|
|
32
42
|
async function fetchUserPRCounts(octokit, githubUsername, query, label, accumulateRepo) {
|
|
33
43
|
if (!githubUsername) {
|
|
34
|
-
return
|
|
44
|
+
return emptyPRCountsResult();
|
|
35
45
|
}
|
|
36
46
|
// Check for a fresh cached result (avoids 10-20 paginated API calls)
|
|
37
47
|
const cache = getHttpCache();
|
|
@@ -53,6 +63,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
53
63
|
const dailyActivityCounts = {};
|
|
54
64
|
let page = 1;
|
|
55
65
|
let fetched = 0;
|
|
66
|
+
let totalCount;
|
|
56
67
|
while (true) {
|
|
57
68
|
const { data } = await octokit.search.issuesAndPullRequests({
|
|
58
69
|
q: `is:pr ${query} author:${githubUsername}`,
|
|
@@ -61,6 +72,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
61
72
|
per_page: 100,
|
|
62
73
|
page,
|
|
63
74
|
});
|
|
75
|
+
totalCount = data.total_count;
|
|
64
76
|
for (const item of data.items) {
|
|
65
77
|
const parsed = extractOwnerRepo(item.html_url);
|
|
66
78
|
if (!parsed) {
|
|
@@ -95,14 +107,22 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
fetched += data.items.length;
|
|
98
|
-
// Stop if we've fetched all results
|
|
99
|
-
|
|
110
|
+
// Stop if we've fetched all results, hit the API limit (1000), or reached max pages
|
|
111
|
+
// GitHub Search API returns 500/ECONNRESET on deep pagination (page 4-5+),
|
|
112
|
+
// so we cap at MAX_PAGINATION_PAGES to avoid taking down the entire startup flow.
|
|
113
|
+
if (fetched >= data.total_count || fetched >= 1000 || data.items.length === 0 || page >= MAX_PAGINATION_PAGES) {
|
|
100
114
|
break;
|
|
101
115
|
}
|
|
102
116
|
page++;
|
|
103
117
|
}
|
|
118
|
+
if (fetched < totalCount && page >= MAX_PAGINATION_PAGES) {
|
|
119
|
+
warn(MODULE, `Pagination capped at ${MAX_PAGINATION_PAGES} pages: fetched ${fetched} of ${totalCount} ${label} PRs. Stats may be incomplete for prolific contributors.`);
|
|
120
|
+
}
|
|
104
121
|
debug(MODULE, `Found ${fetched} ${label} PRs across ${repos.size} repos`);
|
|
105
|
-
// Cache the aggregated result (Map → entries array for JSON serialization)
|
|
122
|
+
// Cache the aggregated result (Map → entries array for JSON serialization).
|
|
123
|
+
// Note: truncated results (from pagination cap) are cached with the same TTL. This is an
|
|
124
|
+
// accepted tradeoff — prolific contributors (300+ PRs) may see slightly incomplete stats
|
|
125
|
+
// for up to 24 hours, but these are cosmetic and self-heal on the next cache expiry.
|
|
106
126
|
cache.set(cacheKey, '', {
|
|
107
127
|
reposEntries: Array.from(repos.entries()),
|
|
108
128
|
monthlyCounts,
|
|
@@ -200,7 +200,7 @@ export interface IssueListInfo {
|
|
|
200
200
|
* Three valid shapes:
|
|
201
201
|
* 1. Setup incomplete: { version, setupComplete: false }
|
|
202
202
|
* 2. Auth failure: { version, setupComplete: true, authError: "..." }
|
|
203
|
-
* 3. Success: { version, setupComplete: true, daily, dashboardPath?, issueList? }
|
|
203
|
+
* 3. Success: { version, setupComplete: true, daily, dashboardPath?, dashboardUrl?, issueList? }
|
|
204
204
|
*/
|
|
205
205
|
export interface StartupOutput {
|
|
206
206
|
version: string;
|
|
@@ -208,6 +208,8 @@ export interface StartupOutput {
|
|
|
208
208
|
authError?: string;
|
|
209
209
|
daily?: DailyOutput;
|
|
210
210
|
dashboardPath?: string;
|
|
211
|
+
/** URL of the interactive SPA dashboard server, when running (e.g., "http://localhost:3000") */
|
|
212
|
+
dashboardUrl?: string;
|
|
211
213
|
issueList?: IssueListInfo;
|
|
212
214
|
}
|
|
213
215
|
/** A single parsed issue from a markdown list (#82) */
|