@oss-autopilot/core 0.42.4 → 0.42.6

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.
@@ -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 { repos: /* @__PURE__ */ new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
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
 
@@ -12943,7 +12955,7 @@ function buildRepoMap(prs, label) {
12943
12955
  const repoMap = /* @__PURE__ */ new Map();
12944
12956
  for (const pr of prs) {
12945
12957
  if (!pr.repo) {
12946
- console.warn(`[${label}] Skipping PR #${pr.number} (${pr.url}) with empty repo field`);
12958
+ warn(label, `Skipping PR #${pr.number} (${pr.url}) with empty repo field`);
12947
12959
  continue;
12948
12960
  }
12949
12961
  const existing = repoMap.get(pr.repo) || [];
@@ -13348,6 +13360,7 @@ var init_daily_logic = __esm({
13348
13360
  "src/core/daily-logic.ts"() {
13349
13361
  "use strict";
13350
13362
  init_utils();
13363
+ init_logger();
13351
13364
  CRITICAL_STATUSES = /* @__PURE__ */ new Set([
13352
13365
  "needs_response",
13353
13366
  "needs_changes",
@@ -13475,8 +13488,14 @@ async function fetchPRData(prMonitor, token) {
13475
13488
  const issueMonitor = new IssueConversationMonitor(token);
13476
13489
  const [mergedResult, closedResult, recentlyClosedPRs, recentlyMergedPRs, issueConversationResult] = await Promise.all(
13477
13490
  [
13478
- prMonitor.fetchUserMergedPRCounts(),
13479
- prMonitor.fetchUserClosedPRCounts(),
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
+ }),
13480
13499
  prMonitor.fetchRecentlyClosedPRs().catch((err) => {
13481
13500
  console.error(`Warning: Failed to fetch recently closed PRs: ${errorMessage(err)}`);
13482
13501
  return [];
@@ -13625,12 +13644,16 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
13625
13644
  function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
13626
13645
  const stateManager2 = getStateManager();
13627
13646
  try {
13628
- stateManager2.setMonthlyMergedCounts(monthlyCounts);
13647
+ if (Object.keys(monthlyCounts).length > 0) {
13648
+ stateManager2.setMonthlyMergedCounts(monthlyCounts);
13649
+ }
13629
13650
  } catch (error) {
13630
13651
  console.error("[DAILY] Failed to store monthly merged counts:", errorMessage(error));
13631
13652
  }
13632
13653
  try {
13633
- stateManager2.setMonthlyClosedCounts(monthlyClosedCounts);
13654
+ if (Object.keys(monthlyClosedCounts).length > 0) {
13655
+ stateManager2.setMonthlyClosedCounts(monthlyClosedCounts);
13656
+ }
13634
13657
  } catch (error) {
13635
13658
  console.error("[DAILY] Failed to store monthly closed counts:", errorMessage(error));
13636
13659
  }
@@ -13645,7 +13668,9 @@ function updateAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerg
13645
13668
  combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
13646
13669
  }
13647
13670
  }
13648
- stateManager2.setMonthlyOpenedCounts(combinedOpenedCounts);
13671
+ if (Object.keys(combinedOpenedCounts).length > 0) {
13672
+ stateManager2.setMonthlyOpenedCounts(combinedOpenedCounts);
13673
+ }
13649
13674
  } catch (error) {
13650
13675
  console.error("[DAILY] Failed to compute/store monthly opened counts:", errorMessage(error));
13651
13676
  }
@@ -13815,6 +13840,7 @@ var init_daily = __esm({
13815
13840
  "use strict";
13816
13841
  init_core();
13817
13842
  init_errors();
13843
+ init_github_stats();
13818
13844
  init_json();
13819
13845
  init_core();
13820
13846
  }
@@ -13947,12 +13973,13 @@ function validateGitHubUsername(username) {
13947
13973
  }
13948
13974
  return trimmed;
13949
13975
  }
13950
- var PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, MAX_URL_LENGTH, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH, USERNAME_CHARS_PATTERN, CONSECUTIVE_HYPHENS_PATTERN;
13976
+ var PR_URL_PATTERN, ISSUE_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, MAX_URL_LENGTH, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH, USERNAME_CHARS_PATTERN, CONSECUTIVE_HYPHENS_PATTERN;
13951
13977
  var init_validation = __esm({
13952
13978
  "src/commands/validation.ts"() {
13953
13979
  "use strict";
13954
13980
  init_errors();
13955
13981
  PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;
13982
+ ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
13956
13983
  ISSUE_OR_PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+$/;
13957
13984
  MAX_URL_LENGTH = 2048;
13958
13985
  MAX_MESSAGE_LENGTH = 1e3;
@@ -14072,6 +14099,7 @@ __export(comments_exports, {
14072
14099
  });
14073
14100
  async function runComments(options) {
14074
14101
  validateUrl(options.prUrl);
14102
+ validateGitHubUrl(options.prUrl, PR_URL_PATTERN, "PR");
14075
14103
  const token = requireGitHubToken();
14076
14104
  const stateManager2 = getStateManager();
14077
14105
  const octokit = getOctokit(token);
@@ -14155,6 +14183,7 @@ async function runComments(options) {
14155
14183
  }
14156
14184
  async function runPost(options) {
14157
14185
  validateUrl(options.url);
14186
+ validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, "issue or PR");
14158
14187
  if (!options.message.trim()) {
14159
14188
  throw new Error("No message provided");
14160
14189
  }
@@ -14179,6 +14208,7 @@ async function runPost(options) {
14179
14208
  }
14180
14209
  async function runClaim(options) {
14181
14210
  validateUrl(options.issueUrl);
14211
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, "issue");
14182
14212
  const token = requireGitHubToken();
14183
14213
  const message = options.message || "Hi! I'd like to work on this issue. Could you assign it to me?";
14184
14214
  validateMessage(message);
@@ -14194,20 +14224,26 @@ async function runClaim(options) {
14194
14224
  issue_number: number,
14195
14225
  body: message
14196
14226
  });
14197
- const stateManager2 = getStateManager();
14198
- stateManager2.addIssue({
14199
- id: number,
14200
- url: options.issueUrl,
14201
- repo: `${owner}/${repo}`,
14202
- number,
14203
- title: "(claimed)",
14204
- status: "claimed",
14205
- labels: [],
14206
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14207
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
14208
- vetted: false
14209
- });
14210
- stateManager2.save();
14227
+ try {
14228
+ const stateManager2 = getStateManager();
14229
+ stateManager2.addIssue({
14230
+ id: number,
14231
+ url: options.issueUrl,
14232
+ repo: `${owner}/${repo}`,
14233
+ number,
14234
+ title: "(claimed)",
14235
+ status: "claimed",
14236
+ labels: [],
14237
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14238
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
14239
+ vetted: false
14240
+ });
14241
+ stateManager2.save();
14242
+ } catch (error) {
14243
+ console.error(
14244
+ `Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`
14245
+ );
14246
+ }
14211
14247
  return {
14212
14248
  commentUrl: comment.html_url,
14213
14249
  issueUrl: options.issueUrl
@@ -14320,6 +14356,13 @@ __export(setup_exports, {
14320
14356
  runCheckSetup: () => runCheckSetup,
14321
14357
  runSetup: () => runSetup
14322
14358
  });
14359
+ function parsePositiveInt(value, settingName) {
14360
+ const parsed = Number(value);
14361
+ if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) {
14362
+ throw new ValidationError(`Invalid value for ${settingName}: "${value}". Must be a positive integer.`);
14363
+ }
14364
+ return parsed;
14365
+ }
14323
14366
  async function runSetup(options) {
14324
14367
  const stateManager2 = getStateManager();
14325
14368
  const config = stateManager2.getState().config;
@@ -14335,18 +14378,24 @@ async function runSetup(options) {
14335
14378
  stateManager2.updateConfig({ githubUsername: value });
14336
14379
  results[key] = value;
14337
14380
  break;
14338
- case "maxActivePRs":
14339
- stateManager2.updateConfig({ maxActivePRs: parseInt(value) || 10 });
14340
- results[key] = value;
14381
+ case "maxActivePRs": {
14382
+ const maxPRs = parsePositiveInt(value, "maxActivePRs");
14383
+ stateManager2.updateConfig({ maxActivePRs: maxPRs });
14384
+ results[key] = String(maxPRs);
14341
14385
  break;
14342
- case "dormantDays":
14343
- stateManager2.updateConfig({ dormantThresholdDays: parseInt(value) || 30 });
14344
- results[key] = value;
14386
+ }
14387
+ case "dormantDays": {
14388
+ const dormant = parsePositiveInt(value, "dormantDays");
14389
+ stateManager2.updateConfig({ dormantThresholdDays: dormant });
14390
+ results[key] = String(dormant);
14345
14391
  break;
14346
- case "approachingDays":
14347
- stateManager2.updateConfig({ approachingDormantDays: parseInt(value) || 25 });
14348
- results[key] = value;
14392
+ }
14393
+ case "approachingDays": {
14394
+ const approaching = parsePositiveInt(value, "approachingDays");
14395
+ stateManager2.updateConfig({ approachingDormantDays: approaching });
14396
+ results[key] = String(approaching);
14349
14397
  break;
14398
+ }
14350
14399
  case "languages":
14351
14400
  stateManager2.updateConfig({ languages: value.split(",").map((l) => l.trim()) });
14352
14401
  results[key] = value;
@@ -14369,9 +14418,12 @@ async function runSetup(options) {
14369
14418
  }
14370
14419
  break;
14371
14420
  case "minStars": {
14372
- const parsed = parseInt(value);
14373
- stateManager2.updateConfig({ minStars: isNaN(parsed) ? 50 : parsed });
14374
- results[key] = value;
14421
+ const stars = Number(value);
14422
+ if (!Number.isFinite(stars) || !Number.isInteger(stars) || stars < 0) {
14423
+ throw new ValidationError(`Invalid value for minStars: "${value}". Must be a non-negative integer.`);
14424
+ }
14425
+ stateManager2.updateConfig({ minStars: stars });
14426
+ results[key] = String(stars);
14375
14427
  break;
14376
14428
  }
14377
14429
  case "includeDocIssues":
@@ -14495,6 +14547,7 @@ var init_setup = __esm({
14495
14547
  "src/commands/setup.ts"() {
14496
14548
  "use strict";
14497
14549
  init_core();
14550
+ init_errors();
14498
14551
  init_validation();
14499
14552
  }
14500
14553
  });
@@ -14514,8 +14567,14 @@ async function fetchDashboardData(token) {
14514
14567
  console.error(`Warning: Failed to fetch recently merged PRs: ${errorMessage(err)}`);
14515
14568
  return [];
14516
14569
  }),
14517
- prMonitor.fetchUserMergedPRCounts(),
14518
- prMonitor.fetchUserClosedPRCounts(),
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
+ }),
14519
14578
  issueMonitor.fetchCommentedIssues().catch((error) => {
14520
14579
  const msg = errorMessage(error);
14521
14580
  if (msg.includes("No GitHub username configured")) {
@@ -14539,12 +14598,16 @@ async function fetchDashboardData(token) {
14539
14598
  const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
14540
14599
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
14541
14600
  try {
14542
- stateManager2.setMonthlyMergedCounts(monthlyCounts);
14601
+ if (Object.keys(monthlyCounts).length > 0) {
14602
+ stateManager2.setMonthlyMergedCounts(monthlyCounts);
14603
+ }
14543
14604
  } catch (error) {
14544
14605
  console.error("[DASHBOARD] Failed to store monthly merged counts:", errorMessage(error));
14545
14606
  }
14546
14607
  try {
14547
- stateManager2.setMonthlyClosedCounts(monthlyClosedCounts);
14608
+ if (Object.keys(monthlyClosedCounts).length > 0) {
14609
+ stateManager2.setMonthlyClosedCounts(monthlyClosedCounts);
14610
+ }
14548
14611
  } catch (error) {
14549
14612
  console.error("[DASHBOARD] Failed to store monthly closed counts:", errorMessage(error));
14550
14613
  }
@@ -14559,7 +14622,9 @@ async function fetchDashboardData(token) {
14559
14622
  combinedOpenedCounts[month] = (combinedOpenedCounts[month] || 0) + 1;
14560
14623
  }
14561
14624
  }
14562
- stateManager2.setMonthlyOpenedCounts(combinedOpenedCounts);
14625
+ if (Object.keys(combinedOpenedCounts).length > 0) {
14626
+ stateManager2.setMonthlyOpenedCounts(combinedOpenedCounts);
14627
+ }
14563
14628
  } catch (error) {
14564
14629
  console.error("[DASHBOARD] Failed to store monthly opened counts:", errorMessage(error));
14565
14630
  }
@@ -14570,7 +14635,11 @@ async function fetchDashboardData(token) {
14570
14635
  digest.autoUnshelvedPRs = [];
14571
14636
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
14572
14637
  stateManager2.setLastDigest(digest);
14573
- stateManager2.save();
14638
+ try {
14639
+ stateManager2.save();
14640
+ } catch (error) {
14641
+ console.error("Warning: Failed to save dashboard digest to state:", errorMessage(error));
14642
+ }
14574
14643
  console.error(`Refreshed: ${prs.length} PRs fetched`);
14575
14644
  return { digest, commentedIssues };
14576
14645
  }
@@ -14602,6 +14671,7 @@ var init_dashboard_data = __esm({
14602
14671
  "use strict";
14603
14672
  init_core();
14604
14673
  init_errors();
14674
+ init_github_stats();
14605
14675
  init_daily();
14606
14676
  }
14607
14677
  });
@@ -16364,6 +16434,38 @@ async function startDashboardServer(options) {
16364
16434
  sendError(res, 400, 'Missing or invalid "url" field');
16365
16435
  return;
16366
16436
  }
16437
+ try {
16438
+ validateUrl(body.url);
16439
+ validateGitHubUrl(body.url, PR_URL_PATTERN, "PR");
16440
+ } catch (err) {
16441
+ if (err instanceof ValidationError) {
16442
+ sendError(res, 400, err.message);
16443
+ } else {
16444
+ console.error("Unexpected error during URL validation:", err);
16445
+ sendError(res, 400, "Invalid URL");
16446
+ }
16447
+ return;
16448
+ }
16449
+ if (body.action === "snooze") {
16450
+ const days = body.days ?? 7;
16451
+ if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) {
16452
+ sendError(res, 400, "Snooze days must be a positive finite number");
16453
+ return;
16454
+ }
16455
+ if (body.reason !== void 0) {
16456
+ try {
16457
+ validateMessage(String(body.reason));
16458
+ } catch (err) {
16459
+ if (err instanceof ValidationError) {
16460
+ sendError(res, 400, err.message);
16461
+ } else {
16462
+ console.error("Unexpected error during message validation:", err);
16463
+ sendError(res, 400, "Invalid reason");
16464
+ }
16465
+ return;
16466
+ }
16467
+ }
16468
+ }
16367
16469
  try {
16368
16470
  switch (body.action) {
16369
16471
  case "shelve":
@@ -16373,7 +16475,7 @@ async function startDashboardServer(options) {
16373
16475
  stateManager2.unshelvePR(body.url);
16374
16476
  break;
16375
16477
  case "snooze":
16376
- stateManager2.snoozePR(body.url, body.reason || "Snoozed via dashboard", body.days || 7);
16478
+ stateManager2.snoozePR(body.url, body.reason || "Snoozed via dashboard", body.days ?? 7);
16377
16479
  break;
16378
16480
  case "unsnooze":
16379
16481
  stateManager2.unsnoozePR(body.url);
@@ -16527,6 +16629,7 @@ var init_dashboard_server = __esm({
16527
16629
  path5 = __toESM(require("path"), 1);
16528
16630
  init_core();
16529
16631
  init_errors();
16632
+ init_validation();
16530
16633
  init_dashboard_data();
16531
16634
  init_dashboard_templates();
16532
16635
  VALID_ACTIONS = /* @__PURE__ */ new Set(["shelve", "unshelve", "snooze", "unsnooze"]);
@@ -16580,6 +16683,8 @@ async function runDashboard(options) {
16580
16683
  digest = stateManager2.getState().lastDigest;
16581
16684
  }
16582
16685
  } else {
16686
+ console.error("Warning: No GitHub token found. Using cached data (may be stale).");
16687
+ console.error("Set GITHUB_TOKEN or run `gh auth login` for fresh data.");
16583
16688
  digest = stateManager2.getState().lastDigest;
16584
16689
  }
16585
16690
  if (!digest) {
@@ -16618,7 +16723,6 @@ async function runDashboard(options) {
16618
16723
  const html = generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state, issueResponses);
16619
16724
  const dashboardPath = getDashboardPath();
16620
16725
  fs6.writeFileSync(dashboardPath, html, { mode: 420 });
16621
- fs6.chmodSync(dashboardPath, 420);
16622
16726
  if (options.offline) {
16623
16727
  const lastUpdated = digest.generatedAt || state.lastDigestAt || state.lastRunAt;
16624
16728
  console.log(`
@@ -16654,7 +16758,6 @@ function writeDashboardFromState() {
16654
16758
  const html = generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state);
16655
16759
  const dashboardPath = getDashboardPath();
16656
16760
  fs6.writeFileSync(dashboardPath, html, { mode: 420 });
16657
- fs6.chmodSync(dashboardPath, 420);
16658
16761
  return dashboardPath;
16659
16762
  }
16660
16763
  function resolveAssetsDir() {
@@ -17043,7 +17146,7 @@ async function runLocalRepos(options) {
17043
17146
  stateManager2.save();
17044
17147
  } catch (error) {
17045
17148
  const msg = errorMessage(error);
17046
- debug("local-repos", `Failed to cache scan results: ${msg}`);
17149
+ console.error(`Warning: Failed to cache scan results: ${msg}`);
17047
17150
  }
17048
17151
  return {
17049
17152
  repos,
@@ -17424,12 +17527,20 @@ Last Run: ${data.lastRunAt || "Never"}`);
17424
17527
  program2.command("search [count]").description("Search for new issues to work on").option("--json", "Output as JSON").action(async (count, options) => {
17425
17528
  try {
17426
17529
  const { runSearch: runSearch2 } = await Promise.resolve().then(() => (init_search(), search_exports));
17530
+ let maxResults = 5;
17531
+ if (count !== void 0) {
17532
+ const parsed = Number(count);
17533
+ if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) {
17534
+ throw new Error(`Invalid count "${count}". Must be a positive integer.`);
17535
+ }
17536
+ maxResults = parsed;
17537
+ }
17427
17538
  if (!options.json) {
17428
17539
  console.log(`
17429
- Searching for issues (max ${parseInt(count) || 5})...
17540
+ Searching for issues (max ${maxResults})...
17430
17541
  `);
17431
17542
  }
17432
- const data = await runSearch2({ maxResults: parseInt(count) || 5 });
17543
+ const data = await runSearch2({ maxResults });
17433
17544
  if (options.json) {
17434
17545
  outputJson(data);
17435
17546
  } else {
@@ -17728,17 +17839,25 @@ program2.command("checkSetup").description("Check if setup is complete").option(
17728
17839
  });
17729
17840
  var dashboardCmd = program2.command("dashboard").description("Dashboard commands");
17730
17841
  dashboardCmd.command("serve").description("Start interactive dashboard server").option("--port <port>", "Port to listen on", "3000").option("--no-open", "Do not open browser automatically").action(async (options) => {
17731
- const port = parseInt(options.port, 10);
17732
- if (isNaN(port) || port < 1 || port > 65535) {
17733
- console.error(`Invalid port number: "${options.port}". Must be an integer between 1 and 65535.`);
17734
- process.exit(1);
17842
+ try {
17843
+ const port = parseInt(options.port, 10);
17844
+ if (isNaN(port) || port < 1 || port > 65535) {
17845
+ console.error(`Invalid port number: "${options.port}". Must be an integer between 1 and 65535.`);
17846
+ process.exit(1);
17847
+ }
17848
+ const { serveDashboard: serveDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17849
+ await serveDashboard2({ port, open: options.open });
17850
+ } catch (err) {
17851
+ handleCommandError(err);
17735
17852
  }
17736
- const { serveDashboard: serveDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17737
- await serveDashboard2({ port, open: options.open });
17738
17853
  });
17739
17854
  dashboardCmd.option("--open", "Open in browser").option("--json", "Output as JSON").option("--offline", "Use cached data only (no GitHub API calls)").action(async (options) => {
17740
- const { runDashboard: runDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17741
- await runDashboard2({ open: options.open, json: options.json, offline: options.offline });
17855
+ try {
17856
+ const { runDashboard: runDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17857
+ await runDashboard2({ open: options.open, json: options.json, offline: options.offline });
17858
+ } catch (err) {
17859
+ handleCommandError(err, options.json);
17860
+ }
17742
17861
  });
17743
17862
  program2.command("parse-issue-list <path>").description("Parse a markdown issue list into structured JSON").option("--json", "Output as JSON").action(async (filePath, options) => {
17744
17863
  try {
package/dist/cli.js CHANGED
@@ -127,10 +127,18 @@ program
127
127
  .action(async (count, options) => {
128
128
  try {
129
129
  const { runSearch } = await import('./commands/search.js');
130
+ let maxResults = 5;
131
+ if (count !== undefined) {
132
+ const parsed = Number(count);
133
+ if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) {
134
+ throw new Error(`Invalid count "${count}". Must be a positive integer.`);
135
+ }
136
+ maxResults = parsed;
137
+ }
130
138
  if (!options.json) {
131
- console.log(`\nSearching for issues (max ${parseInt(count) || 5})...\n`);
139
+ console.log(`\nSearching for issues (max ${maxResults})...\n`);
132
140
  }
133
- const data = await runSearch({ maxResults: parseInt(count) || 5 });
141
+ const data = await runSearch({ maxResults });
134
142
  if (options.json) {
135
143
  outputJson(data);
136
144
  }
@@ -516,13 +524,18 @@ dashboardCmd
516
524
  .option('--port <port>', 'Port to listen on', '3000')
517
525
  .option('--no-open', 'Do not open browser automatically')
518
526
  .action(async (options) => {
519
- const port = parseInt(options.port, 10);
520
- if (isNaN(port) || port < 1 || port > 65535) {
521
- console.error(`Invalid port number: "${options.port}". Must be an integer between 1 and 65535.`);
522
- process.exit(1);
527
+ try {
528
+ const port = parseInt(options.port, 10);
529
+ if (isNaN(port) || port < 1 || port > 65535) {
530
+ console.error(`Invalid port number: "${options.port}". Must be an integer between 1 and 65535.`);
531
+ process.exit(1);
532
+ }
533
+ const { serveDashboard } = await import('./commands/dashboard.js');
534
+ await serveDashboard({ port, open: options.open });
535
+ }
536
+ catch (err) {
537
+ handleCommandError(err);
523
538
  }
524
- const { serveDashboard } = await import('./commands/dashboard.js');
525
- await serveDashboard({ port, open: options.open });
526
539
  });
527
540
  // Keep bare `dashboard` (no subcommand) for backward compat — generates static HTML
528
541
  dashboardCmd
@@ -530,8 +543,13 @@ dashboardCmd
530
543
  .option('--json', 'Output as JSON')
531
544
  .option('--offline', 'Use cached data only (no GitHub API calls)')
532
545
  .action(async (options) => {
533
- const { runDashboard } = await import('./commands/dashboard.js');
534
- await runDashboard({ open: options.open, json: options.json, offline: options.offline });
546
+ try {
547
+ const { runDashboard } = await import('./commands/dashboard.js');
548
+ await runDashboard({ open: options.open, json: options.json, offline: options.offline });
549
+ }
550
+ catch (err) {
551
+ handleCommandError(err, options.json);
552
+ }
535
553
  });
536
554
  // Parse issue list command (#82)
537
555
  program
@@ -4,9 +4,10 @@
4
4
  */
5
5
  import { getStateManager, getOctokit, parseGitHubUrl, requireGitHubToken } from '../core/index.js';
6
6
  import { paginateAll } from '../core/pagination.js';
7
- import { validateUrl, validateMessage } from './validation.js';
7
+ import { validateUrl, validateMessage, validateGitHubUrl, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, ISSUE_URL_PATTERN, } from './validation.js';
8
8
  export async function runComments(options) {
9
9
  validateUrl(options.prUrl);
10
+ validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
10
11
  const token = requireGitHubToken();
11
12
  const stateManager = getStateManager();
12
13
  const octokit = getOctokit(token);
@@ -97,6 +98,7 @@ export async function runComments(options) {
97
98
  }
98
99
  export async function runPost(options) {
99
100
  validateUrl(options.url);
101
+ validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
100
102
  if (!options.message.trim()) {
101
103
  throw new Error('No message provided');
102
104
  }
@@ -122,6 +124,7 @@ export async function runPost(options) {
122
124
  }
123
125
  export async function runClaim(options) {
124
126
  validateUrl(options.issueUrl);
127
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
125
128
  const token = requireGitHubToken();
126
129
  // Default claim message or custom
127
130
  const message = options.message || "Hi! I'd like to work on this issue. Could you assign it to me?";
@@ -139,21 +142,26 @@ export async function runClaim(options) {
139
142
  issue_number: number,
140
143
  body: message,
141
144
  });
142
- // Add to tracked issues
143
- const stateManager = getStateManager();
144
- stateManager.addIssue({
145
- id: number,
146
- url: options.issueUrl,
147
- repo: `${owner}/${repo}`,
148
- number,
149
- title: '(claimed)',
150
- status: 'claimed',
151
- labels: [],
152
- createdAt: new Date().toISOString(),
153
- updatedAt: new Date().toISOString(),
154
- vetted: false,
155
- });
156
- stateManager.save();
145
+ // Add to tracked issues — non-fatal if state save fails (comment already posted)
146
+ try {
147
+ const stateManager = getStateManager();
148
+ stateManager.addIssue({
149
+ id: number,
150
+ url: options.issueUrl,
151
+ repo: `${owner}/${repo}`,
152
+ number,
153
+ title: '(claimed)',
154
+ status: 'claimed',
155
+ labels: [],
156
+ createdAt: new Date().toISOString(),
157
+ updatedAt: new Date().toISOString(),
158
+ vetted: false,
159
+ });
160
+ stateManager.save();
161
+ }
162
+ catch (error) {
163
+ console.error(`Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`);
164
+ }
157
165
  return {
158
166
  commentUrl: comment.html_url,
159
167
  issueUrl: options.issueUrl,
@@ -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
- // Recently closed/merged are non-critical (cosmetic sections), so isolate their failure
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
- prMonitor.fetchUserClosedPRCounts(),
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
- stateManager.setMonthlyMergedCounts(monthlyCounts);
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
- stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
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
- stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
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
- prMonitor.fetchUserClosedPRCounts(),
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
- stateManager.setMonthlyMergedCounts(monthlyCounts);
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
- stateManager.setMonthlyClosedCounts(monthlyClosedCounts);
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
- stateManager.setMonthlyOpenedCounts(combinedOpenedCounts);
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));
@@ -88,7 +102,12 @@ export async function fetchDashboardData(token) {
88
102
  digest.autoUnshelvedPRs = [];
89
103
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
90
104
  stateManager.setLastDigest(digest);
91
- stateManager.save();
105
+ try {
106
+ stateManager.save();
107
+ }
108
+ catch (error) {
109
+ console.error('Warning: Failed to save dashboard digest to state:', errorMessage(error));
110
+ }
92
111
  console.error(`Refreshed: ${prs.length} PRs fetched`);
93
112
  return { digest, commentedIssues };
94
113
  }
@@ -9,7 +9,8 @@ import * as http from 'http';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { getStateManager, getGitHubToken } from '../core/index.js';
12
- import { errorMessage } from '../core/errors.js';
12
+ import { errorMessage, ValidationError } from '../core/errors.js';
13
+ import { validateUrl, validateGitHubUrl, validateMessage, PR_URL_PATTERN } from './validation.js';
13
14
  import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData } from './dashboard-data.js';
14
15
  import { buildDashboardStats } from './dashboard-templates.js';
15
16
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -185,6 +186,44 @@ export async function startDashboardServer(options) {
185
186
  sendError(res, 400, 'Missing or invalid "url" field');
186
187
  return;
187
188
  }
189
+ // Validate URL format — same checks as CLI commands
190
+ try {
191
+ validateUrl(body.url);
192
+ validateGitHubUrl(body.url, PR_URL_PATTERN, 'PR');
193
+ }
194
+ catch (err) {
195
+ if (err instanceof ValidationError) {
196
+ sendError(res, 400, err.message);
197
+ }
198
+ else {
199
+ console.error('Unexpected error during URL validation:', err);
200
+ sendError(res, 400, 'Invalid URL');
201
+ }
202
+ return;
203
+ }
204
+ // Validate snooze-specific fields
205
+ if (body.action === 'snooze') {
206
+ const days = body.days ?? 7;
207
+ if (typeof days !== 'number' || !Number.isFinite(days) || days <= 0) {
208
+ sendError(res, 400, 'Snooze days must be a positive finite number');
209
+ return;
210
+ }
211
+ if (body.reason !== undefined) {
212
+ try {
213
+ validateMessage(String(body.reason));
214
+ }
215
+ catch (err) {
216
+ if (err instanceof ValidationError) {
217
+ sendError(res, 400, err.message);
218
+ }
219
+ else {
220
+ console.error('Unexpected error during message validation:', err);
221
+ sendError(res, 400, 'Invalid reason');
222
+ }
223
+ return;
224
+ }
225
+ }
226
+ }
188
227
  try {
189
228
  switch (body.action) {
190
229
  case 'shelve':
@@ -194,7 +233,7 @@ export async function startDashboardServer(options) {
194
233
  stateManager.unshelvePR(body.url);
195
234
  break;
196
235
  case 'snooze':
197
- stateManager.snoozePR(body.url, body.reason || 'Snoozed via dashboard', body.days || 7);
236
+ stateManager.snoozePR(body.url, body.reason || 'Snoozed via dashboard', body.days ?? 7);
198
237
  break;
199
238
  case 'unsnooze':
200
239
  stateManager.unsnoozePR(body.url);
@@ -46,7 +46,9 @@ export async function runDashboard(options) {
46
46
  }
47
47
  }
48
48
  else {
49
- // No token and not offline — fall back to cached digest
49
+ // No token and not offline — fall back to cached digest with warning
50
+ console.error('Warning: No GitHub token found. Using cached data (may be stale).');
51
+ console.error('Set GITHUB_TOKEN or run `gh auth login` for fresh data.');
50
52
  digest = stateManager.getState().lastDigest;
51
53
  }
52
54
  // Check if we have a digest to display
@@ -89,7 +91,6 @@ export async function runDashboard(options) {
89
91
  // Write to file in ~/.oss-autopilot/
90
92
  const dashboardPath = getDashboardPath();
91
93
  fs.writeFileSync(dashboardPath, html, { mode: 0o644 });
92
- fs.chmodSync(dashboardPath, 0o644);
93
94
  if (options.offline) {
94
95
  const lastUpdated = digest.generatedAt || state.lastDigestAt || state.lastRunAt;
95
96
  console.log(`\n📊 Dashboard generated (offline, cached data from ${lastUpdated}): ${dashboardPath}`);
@@ -131,7 +132,6 @@ export function writeDashboardFromState() {
131
132
  const html = generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state);
132
133
  const dashboardPath = getDashboardPath();
133
134
  fs.writeFileSync(dashboardPath, html, { mode: 0o644 });
134
- fs.chmodSync(dashboardPath, 0o644);
135
135
  return dashboardPath;
136
136
  }
137
137
  /**
@@ -117,7 +117,7 @@ export async function runLocalRepos(options) {
117
117
  }
118
118
  catch (error) {
119
119
  const msg = errorMessage(error);
120
- debug('local-repos', `Failed to cache scan results: ${msg}`);
120
+ console.error(`Warning: Failed to cache scan results: ${msg}`);
121
121
  }
122
122
  return {
123
123
  repos,
@@ -3,7 +3,16 @@
3
3
  * Interactive setup / configuration
4
4
  */
5
5
  import { getStateManager, DEFAULT_CONFIG } from '../core/index.js';
6
+ import { ValidationError } from '../core/errors.js';
6
7
  import { validateGitHubUsername } from './validation.js';
8
+ /** Parse and validate a positive integer setting value. */
9
+ function parsePositiveInt(value, settingName) {
10
+ const parsed = Number(value);
11
+ if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) {
12
+ throw new ValidationError(`Invalid value for ${settingName}: "${value}". Must be a positive integer.`);
13
+ }
14
+ return parsed;
15
+ }
7
16
  export async function runSetup(options) {
8
17
  const stateManager = getStateManager();
9
18
  const config = stateManager.getState().config;
@@ -20,18 +29,24 @@ export async function runSetup(options) {
20
29
  stateManager.updateConfig({ githubUsername: value });
21
30
  results[key] = value;
22
31
  break;
23
- case 'maxActivePRs':
24
- stateManager.updateConfig({ maxActivePRs: parseInt(value) || 10 });
25
- results[key] = value;
32
+ case 'maxActivePRs': {
33
+ const maxPRs = parsePositiveInt(value, 'maxActivePRs');
34
+ stateManager.updateConfig({ maxActivePRs: maxPRs });
35
+ results[key] = String(maxPRs);
26
36
  break;
27
- case 'dormantDays':
28
- stateManager.updateConfig({ dormantThresholdDays: parseInt(value) || 30 });
29
- results[key] = value;
37
+ }
38
+ case 'dormantDays': {
39
+ const dormant = parsePositiveInt(value, 'dormantDays');
40
+ stateManager.updateConfig({ dormantThresholdDays: dormant });
41
+ results[key] = String(dormant);
30
42
  break;
31
- case 'approachingDays':
32
- stateManager.updateConfig({ approachingDormantDays: parseInt(value) || 25 });
33
- results[key] = value;
43
+ }
44
+ case 'approachingDays': {
45
+ const approaching = parsePositiveInt(value, 'approachingDays');
46
+ stateManager.updateConfig({ approachingDormantDays: approaching });
47
+ results[key] = String(approaching);
34
48
  break;
49
+ }
35
50
  case 'languages':
36
51
  stateManager.updateConfig({ languages: value.split(',').map((l) => l.trim()) });
37
52
  results[key] = value;
@@ -55,9 +70,12 @@ export async function runSetup(options) {
55
70
  }
56
71
  break;
57
72
  case 'minStars': {
58
- const parsed = parseInt(value);
59
- stateManager.updateConfig({ minStars: isNaN(parsed) ? 50 : parsed });
60
- results[key] = value;
73
+ const stars = Number(value);
74
+ if (!Number.isFinite(stars) || !Number.isInteger(stars) || stars < 0) {
75
+ throw new ValidationError(`Invalid value for minStars: "${value}". Must be a non-negative integer.`);
76
+ }
77
+ stateManager.updateConfig({ minStars: stars });
78
+ results[key] = String(stars);
61
79
  break;
62
80
  }
63
81
  case 'includeDocIssues':
@@ -11,6 +11,7 @@
11
11
  * - formatBriefSummary / formatSummary / printDigest — rendering
12
12
  */
13
13
  import { formatRelativeTime } from './utils.js';
14
+ import { warn } from './logger.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Constants
16
17
  // ---------------------------------------------------------------------------
@@ -45,7 +46,7 @@ function buildRepoMap(prs, label) {
45
46
  const repoMap = new Map();
46
47
  for (const pr of prs) {
47
48
  if (!pr.repo) {
48
- console.warn(`[${label}] Skipping PR #${pr.number} (${pr.url}) with empty repo field`);
49
+ warn(label, `Skipping PR #${pr.number} (${pr.url}) with empty repo field`);
49
50
  continue;
50
51
  }
51
52
  const existing = repoMap.get(pr.repo) || [];
@@ -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 (1 hour). */
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 (1 hour). */
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 { repos: new Map(), monthlyCounts: {}, monthlyOpenedCounts: {}, dailyActivityCounts: {} };
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 or hit the API limit (1000)
99
- if (fetched >= data.total_count || fetched >= 1000 || data.items.length === 0) {
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.42.4",
3
+ "version": "0.42.6",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {