@oss-autopilot/core 0.42.3 → 0.42.5

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.
@@ -11213,7 +11213,24 @@ var init_pr_monitor = __esm({
11213
11213
  );
11214
11214
  const ciPromise = this.getCIStatus(owner, repo, ghPR.head.sha);
11215
11215
  const needCommitDate = hasUnrespondedComment || reviewDecision === "changes_requested";
11216
- const commitDatePromise = needCommitDate ? this.octokit.repos.getCommit({ owner, repo, ref: ghPR.head.sha }).then((res) => res.data.commit.author?.date).catch(() => void 0) : Promise.resolve(void 0);
11216
+ const commitDatePromise = needCommitDate ? this.octokit.repos.getCommit({ owner, repo, ref: ghPR.head.sha }).then((res) => res.data.commit.author?.date).catch((err) => {
11217
+ const status2 = getHttpStatusCode(err);
11218
+ if (status2 === 429) throw err;
11219
+ if (status2 === 403) {
11220
+ const msg = errorMessage(err).toLowerCase();
11221
+ if (msg.includes("rate limit") || msg.includes("abuse detection")) throw err;
11222
+ warn(
11223
+ "pr-monitor",
11224
+ `403 fetching commit date for ${owner}/${repo}@${ghPR.head.sha.slice(0, 7)}: ${errorMessage(err)}`
11225
+ );
11226
+ return void 0;
11227
+ }
11228
+ warn(
11229
+ "pr-monitor",
11230
+ `Failed to fetch commit date for ${owner}/${repo}@${ghPR.head.sha.slice(0, 7)}: ${errorMessage(err)}`
11231
+ );
11232
+ return void 0;
11233
+ }) : Promise.resolve(void 0);
11217
11234
  const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, latestCommitDate] = await Promise.all([
11218
11235
  ciPromise,
11219
11236
  commitDatePromise
@@ -12926,7 +12943,7 @@ function buildRepoMap(prs, label) {
12926
12943
  const repoMap = /* @__PURE__ */ new Map();
12927
12944
  for (const pr of prs) {
12928
12945
  if (!pr.repo) {
12929
- console.warn(`[${label}] Skipping PR #${pr.number} (${pr.url}) with empty repo field`);
12946
+ warn(label, `Skipping PR #${pr.number} (${pr.url}) with empty repo field`);
12930
12947
  continue;
12931
12948
  }
12932
12949
  const existing = repoMap.get(pr.repo) || [];
@@ -13331,6 +13348,7 @@ var init_daily_logic = __esm({
13331
13348
  "src/core/daily-logic.ts"() {
13332
13349
  "use strict";
13333
13350
  init_utils();
13351
+ init_logger();
13334
13352
  CRITICAL_STATUSES = /* @__PURE__ */ new Set([
13335
13353
  "needs_response",
13336
13354
  "needs_changes",
@@ -13685,11 +13703,12 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
13685
13703
  const dismissTime = new Date(dismissedAt).getTime();
13686
13704
  if (isNaN(responseTime) || isNaN(dismissTime)) {
13687
13705
  console.error(`[DAILY] Invalid timestamp in dismiss check for ${issue.url}, including issue`);
13688
- stateManager2.undismissIssue(issue.url);
13689
- hasAutoUndismissed = true;
13690
13706
  return true;
13691
13707
  }
13692
13708
  if (responseTime > dismissTime) {
13709
+ console.error(
13710
+ `[DAILY] Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`
13711
+ );
13693
13712
  stateManager2.undismissIssue(issue.url);
13694
13713
  hasAutoUndismissed = true;
13695
13714
  return true;
@@ -13697,9 +13716,6 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
13697
13716
  }
13698
13717
  return false;
13699
13718
  });
13700
- if (hasAutoUndismissed) {
13701
- stateManager2.save();
13702
- }
13703
13719
  const issueResponses = filteredCommentedIssues.filter(
13704
13720
  (i) => i.status === "new_response"
13705
13721
  );
@@ -13707,8 +13723,32 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
13707
13723
  const snoozedUrls = new Set(
13708
13724
  Object.keys(stateManager2.getState().config.snoozedPRs ?? {}).filter((url) => stateManager2.isSnoozed(url))
13709
13725
  );
13710
- const dismissedUrls = new Set(Object.keys(stateManager2.getState().config.dismissedIssues ?? {}));
13711
- const nonDismissedPRs = activePRs.filter((pr) => !dismissedUrls.has(pr.url));
13726
+ const nonDismissedPRs = activePRs.filter((pr) => {
13727
+ const dismissedAt = stateManager2.getIssueDismissedAt(pr.url);
13728
+ if (!dismissedAt) return true;
13729
+ const activityTime = new Date(pr.updatedAt).getTime();
13730
+ const dismissTime = new Date(dismissedAt).getTime();
13731
+ if (isNaN(activityTime) || isNaN(dismissTime)) {
13732
+ console.error(`[DAILY] Invalid timestamp in PR dismiss check for ${pr.url}, including PR`);
13733
+ return true;
13734
+ }
13735
+ if (activityTime > dismissTime) {
13736
+ console.error(
13737
+ `[DAILY] Auto-undismissing PR ${pr.url}: new activity at ${pr.updatedAt} after dismiss at ${dismissedAt}`
13738
+ );
13739
+ stateManager2.undismissIssue(pr.url);
13740
+ hasAutoUndismissed = true;
13741
+ return true;
13742
+ }
13743
+ return false;
13744
+ });
13745
+ if (hasAutoUndismissed) {
13746
+ try {
13747
+ stateManager2.save();
13748
+ } catch (error) {
13749
+ console.error("[DAILY] Failed to persist auto-undismissed state:", errorMessage(error));
13750
+ }
13751
+ }
13712
13752
  const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls);
13713
13753
  digest.summary.totalNeedingAttention = actionableIssues.length;
13714
13754
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
@@ -13908,12 +13948,13 @@ function validateGitHubUsername(username) {
13908
13948
  }
13909
13949
  return trimmed;
13910
13950
  }
13911
- var PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN, MAX_URL_LENGTH, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH, USERNAME_CHARS_PATTERN, CONSECUTIVE_HYPHENS_PATTERN;
13951
+ 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;
13912
13952
  var init_validation = __esm({
13913
13953
  "src/commands/validation.ts"() {
13914
13954
  "use strict";
13915
13955
  init_errors();
13916
13956
  PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;
13957
+ ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
13917
13958
  ISSUE_OR_PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+$/;
13918
13959
  MAX_URL_LENGTH = 2048;
13919
13960
  MAX_MESSAGE_LENGTH = 1e3;
@@ -14033,6 +14074,7 @@ __export(comments_exports, {
14033
14074
  });
14034
14075
  async function runComments(options) {
14035
14076
  validateUrl(options.prUrl);
14077
+ validateGitHubUrl(options.prUrl, PR_URL_PATTERN, "PR");
14036
14078
  const token = requireGitHubToken();
14037
14079
  const stateManager2 = getStateManager();
14038
14080
  const octokit = getOctokit(token);
@@ -14116,6 +14158,7 @@ async function runComments(options) {
14116
14158
  }
14117
14159
  async function runPost(options) {
14118
14160
  validateUrl(options.url);
14161
+ validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, "issue or PR");
14119
14162
  if (!options.message.trim()) {
14120
14163
  throw new Error("No message provided");
14121
14164
  }
@@ -14140,6 +14183,7 @@ async function runPost(options) {
14140
14183
  }
14141
14184
  async function runClaim(options) {
14142
14185
  validateUrl(options.issueUrl);
14186
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, "issue");
14143
14187
  const token = requireGitHubToken();
14144
14188
  const message = options.message || "Hi! I'd like to work on this issue. Could you assign it to me?";
14145
14189
  validateMessage(message);
@@ -14155,20 +14199,26 @@ async function runClaim(options) {
14155
14199
  issue_number: number,
14156
14200
  body: message
14157
14201
  });
14158
- const stateManager2 = getStateManager();
14159
- stateManager2.addIssue({
14160
- id: number,
14161
- url: options.issueUrl,
14162
- repo: `${owner}/${repo}`,
14163
- number,
14164
- title: "(claimed)",
14165
- status: "claimed",
14166
- labels: [],
14167
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14168
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
14169
- vetted: false
14170
- });
14171
- stateManager2.save();
14202
+ try {
14203
+ const stateManager2 = getStateManager();
14204
+ stateManager2.addIssue({
14205
+ id: number,
14206
+ url: options.issueUrl,
14207
+ repo: `${owner}/${repo}`,
14208
+ number,
14209
+ title: "(claimed)",
14210
+ status: "claimed",
14211
+ labels: [],
14212
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14213
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
14214
+ vetted: false
14215
+ });
14216
+ stateManager2.save();
14217
+ } catch (error) {
14218
+ console.error(
14219
+ `Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`
14220
+ );
14221
+ }
14172
14222
  return {
14173
14223
  commentUrl: comment.html_url,
14174
14224
  issueUrl: options.issueUrl
@@ -14281,6 +14331,13 @@ __export(setup_exports, {
14281
14331
  runCheckSetup: () => runCheckSetup,
14282
14332
  runSetup: () => runSetup
14283
14333
  });
14334
+ function parsePositiveInt(value, settingName) {
14335
+ const parsed = Number(value);
14336
+ if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) {
14337
+ throw new ValidationError(`Invalid value for ${settingName}: "${value}". Must be a positive integer.`);
14338
+ }
14339
+ return parsed;
14340
+ }
14284
14341
  async function runSetup(options) {
14285
14342
  const stateManager2 = getStateManager();
14286
14343
  const config = stateManager2.getState().config;
@@ -14296,18 +14353,24 @@ async function runSetup(options) {
14296
14353
  stateManager2.updateConfig({ githubUsername: value });
14297
14354
  results[key] = value;
14298
14355
  break;
14299
- case "maxActivePRs":
14300
- stateManager2.updateConfig({ maxActivePRs: parseInt(value) || 10 });
14301
- results[key] = value;
14356
+ case "maxActivePRs": {
14357
+ const maxPRs = parsePositiveInt(value, "maxActivePRs");
14358
+ stateManager2.updateConfig({ maxActivePRs: maxPRs });
14359
+ results[key] = String(maxPRs);
14302
14360
  break;
14303
- case "dormantDays":
14304
- stateManager2.updateConfig({ dormantThresholdDays: parseInt(value) || 30 });
14305
- results[key] = value;
14361
+ }
14362
+ case "dormantDays": {
14363
+ const dormant = parsePositiveInt(value, "dormantDays");
14364
+ stateManager2.updateConfig({ dormantThresholdDays: dormant });
14365
+ results[key] = String(dormant);
14306
14366
  break;
14307
- case "approachingDays":
14308
- stateManager2.updateConfig({ approachingDormantDays: parseInt(value) || 25 });
14309
- results[key] = value;
14367
+ }
14368
+ case "approachingDays": {
14369
+ const approaching = parsePositiveInt(value, "approachingDays");
14370
+ stateManager2.updateConfig({ approachingDormantDays: approaching });
14371
+ results[key] = String(approaching);
14310
14372
  break;
14373
+ }
14311
14374
  case "languages":
14312
14375
  stateManager2.updateConfig({ languages: value.split(",").map((l) => l.trim()) });
14313
14376
  results[key] = value;
@@ -14330,9 +14393,12 @@ async function runSetup(options) {
14330
14393
  }
14331
14394
  break;
14332
14395
  case "minStars": {
14333
- const parsed = parseInt(value);
14334
- stateManager2.updateConfig({ minStars: isNaN(parsed) ? 50 : parsed });
14335
- results[key] = value;
14396
+ const stars = Number(value);
14397
+ if (!Number.isFinite(stars) || !Number.isInteger(stars) || stars < 0) {
14398
+ throw new ValidationError(`Invalid value for minStars: "${value}". Must be a non-negative integer.`);
14399
+ }
14400
+ stateManager2.updateConfig({ minStars: stars });
14401
+ results[key] = String(stars);
14336
14402
  break;
14337
14403
  }
14338
14404
  case "includeDocIssues":
@@ -14456,6 +14522,7 @@ var init_setup = __esm({
14456
14522
  "src/commands/setup.ts"() {
14457
14523
  "use strict";
14458
14524
  init_core();
14525
+ init_errors();
14459
14526
  init_validation();
14460
14527
  }
14461
14528
  });
@@ -14531,7 +14598,11 @@ async function fetchDashboardData(token) {
14531
14598
  digest.autoUnshelvedPRs = [];
14532
14599
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
14533
14600
  stateManager2.setLastDigest(digest);
14534
- stateManager2.save();
14601
+ try {
14602
+ stateManager2.save();
14603
+ } catch (error) {
14604
+ console.error("Warning: Failed to save dashboard digest to state:", errorMessage(error));
14605
+ }
14535
14606
  console.error(`Refreshed: ${prs.length} PRs fetched`);
14536
14607
  return { digest, commentedIssues };
14537
14608
  }
@@ -16325,6 +16396,38 @@ async function startDashboardServer(options) {
16325
16396
  sendError(res, 400, 'Missing or invalid "url" field');
16326
16397
  return;
16327
16398
  }
16399
+ try {
16400
+ validateUrl(body.url);
16401
+ validateGitHubUrl(body.url, PR_URL_PATTERN, "PR");
16402
+ } catch (err) {
16403
+ if (err instanceof ValidationError) {
16404
+ sendError(res, 400, err.message);
16405
+ } else {
16406
+ console.error("Unexpected error during URL validation:", err);
16407
+ sendError(res, 400, "Invalid URL");
16408
+ }
16409
+ return;
16410
+ }
16411
+ if (body.action === "snooze") {
16412
+ const days = body.days ?? 7;
16413
+ if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) {
16414
+ sendError(res, 400, "Snooze days must be a positive finite number");
16415
+ return;
16416
+ }
16417
+ if (body.reason !== void 0) {
16418
+ try {
16419
+ validateMessage(String(body.reason));
16420
+ } catch (err) {
16421
+ if (err instanceof ValidationError) {
16422
+ sendError(res, 400, err.message);
16423
+ } else {
16424
+ console.error("Unexpected error during message validation:", err);
16425
+ sendError(res, 400, "Invalid reason");
16426
+ }
16427
+ return;
16428
+ }
16429
+ }
16430
+ }
16328
16431
  try {
16329
16432
  switch (body.action) {
16330
16433
  case "shelve":
@@ -16334,7 +16437,7 @@ async function startDashboardServer(options) {
16334
16437
  stateManager2.unshelvePR(body.url);
16335
16438
  break;
16336
16439
  case "snooze":
16337
- stateManager2.snoozePR(body.url, body.reason || "Snoozed via dashboard", body.days || 7);
16440
+ stateManager2.snoozePR(body.url, body.reason || "Snoozed via dashboard", body.days ?? 7);
16338
16441
  break;
16339
16442
  case "unsnooze":
16340
16443
  stateManager2.unsnoozePR(body.url);
@@ -16488,6 +16591,7 @@ var init_dashboard_server = __esm({
16488
16591
  path5 = __toESM(require("path"), 1);
16489
16592
  init_core();
16490
16593
  init_errors();
16594
+ init_validation();
16491
16595
  init_dashboard_data();
16492
16596
  init_dashboard_templates();
16493
16597
  VALID_ACTIONS = /* @__PURE__ */ new Set(["shelve", "unshelve", "snooze", "unsnooze"]);
@@ -16541,6 +16645,8 @@ async function runDashboard(options) {
16541
16645
  digest = stateManager2.getState().lastDigest;
16542
16646
  }
16543
16647
  } else {
16648
+ console.error("Warning: No GitHub token found. Using cached data (may be stale).");
16649
+ console.error("Set GITHUB_TOKEN or run `gh auth login` for fresh data.");
16544
16650
  digest = stateManager2.getState().lastDigest;
16545
16651
  }
16546
16652
  if (!digest) {
@@ -16579,7 +16685,6 @@ async function runDashboard(options) {
16579
16685
  const html = generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state, issueResponses);
16580
16686
  const dashboardPath = getDashboardPath();
16581
16687
  fs6.writeFileSync(dashboardPath, html, { mode: 420 });
16582
- fs6.chmodSync(dashboardPath, 420);
16583
16688
  if (options.offline) {
16584
16689
  const lastUpdated = digest.generatedAt || state.lastDigestAt || state.lastRunAt;
16585
16690
  console.log(`
@@ -16615,7 +16720,6 @@ function writeDashboardFromState() {
16615
16720
  const html = generateDashboardHtml(stats, monthlyMerged, monthlyClosed, monthlyOpened, digest, state);
16616
16721
  const dashboardPath = getDashboardPath();
16617
16722
  fs6.writeFileSync(dashboardPath, html, { mode: 420 });
16618
- fs6.chmodSync(dashboardPath, 420);
16619
16723
  return dashboardPath;
16620
16724
  }
16621
16725
  function resolveAssetsDir() {
@@ -17004,7 +17108,7 @@ async function runLocalRepos(options) {
17004
17108
  stateManager2.save();
17005
17109
  } catch (error) {
17006
17110
  const msg = errorMessage(error);
17007
- debug("local-repos", `Failed to cache scan results: ${msg}`);
17111
+ console.error(`Warning: Failed to cache scan results: ${msg}`);
17008
17112
  }
17009
17113
  return {
17010
17114
  repos,
@@ -17385,12 +17489,20 @@ Last Run: ${data.lastRunAt || "Never"}`);
17385
17489
  program2.command("search [count]").description("Search for new issues to work on").option("--json", "Output as JSON").action(async (count, options) => {
17386
17490
  try {
17387
17491
  const { runSearch: runSearch2 } = await Promise.resolve().then(() => (init_search(), search_exports));
17492
+ let maxResults = 5;
17493
+ if (count !== void 0) {
17494
+ const parsed = Number(count);
17495
+ if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) {
17496
+ throw new Error(`Invalid count "${count}". Must be a positive integer.`);
17497
+ }
17498
+ maxResults = parsed;
17499
+ }
17388
17500
  if (!options.json) {
17389
17501
  console.log(`
17390
- Searching for issues (max ${parseInt(count) || 5})...
17502
+ Searching for issues (max ${maxResults})...
17391
17503
  `);
17392
17504
  }
17393
- const data = await runSearch2({ maxResults: parseInt(count) || 5 });
17505
+ const data = await runSearch2({ maxResults });
17394
17506
  if (options.json) {
17395
17507
  outputJson(data);
17396
17508
  } else {
@@ -17689,17 +17801,25 @@ program2.command("checkSetup").description("Check if setup is complete").option(
17689
17801
  });
17690
17802
  var dashboardCmd = program2.command("dashboard").description("Dashboard commands");
17691
17803
  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) => {
17692
- const port = parseInt(options.port, 10);
17693
- if (isNaN(port) || port < 1 || port > 65535) {
17694
- console.error(`Invalid port number: "${options.port}". Must be an integer between 1 and 65535.`);
17695
- process.exit(1);
17804
+ try {
17805
+ const port = parseInt(options.port, 10);
17806
+ if (isNaN(port) || port < 1 || port > 65535) {
17807
+ console.error(`Invalid port number: "${options.port}". Must be an integer between 1 and 65535.`);
17808
+ process.exit(1);
17809
+ }
17810
+ const { serveDashboard: serveDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17811
+ await serveDashboard2({ port, open: options.open });
17812
+ } catch (err) {
17813
+ handleCommandError(err);
17696
17814
  }
17697
- const { serveDashboard: serveDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17698
- await serveDashboard2({ port, open: options.open });
17699
17815
  });
17700
17816
  dashboardCmd.option("--open", "Open in browser").option("--json", "Output as JSON").option("--offline", "Use cached data only (no GitHub API calls)").action(async (options) => {
17701
- const { runDashboard: runDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17702
- await runDashboard2({ open: options.open, json: options.json, offline: options.offline });
17817
+ try {
17818
+ const { runDashboard: runDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
17819
+ await runDashboard2({ open: options.open, json: options.json, offline: options.offline });
17820
+ } catch (err) {
17821
+ handleCommandError(err, options.json);
17822
+ }
17703
17823
  });
17704
17824
  program2.command("parse-issue-list <path>").description("Parse a markdown issue list into structured JSON").option("--json", "Output as JSON").action(async (filePath, options) => {
17705
17825
  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,
@@ -305,14 +305,14 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
305
305
  const responseTime = new Date(issue.lastResponseAt).getTime();
306
306
  const dismissTime = new Date(dismissedAt).getTime();
307
307
  if (isNaN(responseTime) || isNaN(dismissTime)) {
308
- // Invalid timestamp — fail open (include issue to be safe)
308
+ // Invalid timestamp — fail open (include issue to be safe) without
309
+ // permanently removing dismiss record (may be a transient data issue)
309
310
  console.error(`[DAILY] Invalid timestamp in dismiss check for ${issue.url}, including issue`);
310
- stateManager.undismissIssue(issue.url);
311
- hasAutoUndismissed = true;
312
311
  return true;
313
312
  }
314
313
  if (responseTime > dismissTime) {
315
314
  // New activity after dismiss — auto-undismiss and resurface
315
+ console.error(`[DAILY] Auto-undismissing issue ${issue.url}: new response at ${issue.lastResponseAt} after dismiss at ${dismissedAt}`);
316
316
  stateManager.undismissIssue(issue.url);
317
317
  hasAutoUndismissed = true;
318
318
  return true;
@@ -321,15 +321,41 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
321
321
  // Still dismissed (last response is at or before dismiss timestamp)
322
322
  return false;
323
323
  });
324
- if (hasAutoUndismissed) {
325
- stateManager.save();
326
- }
327
324
  const issueResponses = filteredCommentedIssues.filter((i) => i.status === 'new_response');
328
325
  const summary = formatSummary(digest, capacity, issueResponses);
329
326
  const snoozedUrls = new Set(Object.keys(stateManager.getState().config.snoozedPRs ?? {}).filter((url) => stateManager.isSnoozed(url)));
330
- // Filter dismissed PR URLs from actionable issues (#416)
331
- const dismissedUrls = new Set(Object.keys(stateManager.getState().config.dismissedIssues ?? {}));
332
- const nonDismissedPRs = activePRs.filter((pr) => !dismissedUrls.has(pr.url));
327
+ // Filter dismissed PRs: suppress if dismissed after last activity, auto-undismiss if new activity (#416, #468)
328
+ const nonDismissedPRs = activePRs.filter((pr) => {
329
+ const dismissedAt = stateManager.getIssueDismissedAt(pr.url);
330
+ if (!dismissedAt)
331
+ return true; // Not dismissed — include
332
+ const activityTime = new Date(pr.updatedAt).getTime();
333
+ const dismissTime = new Date(dismissedAt).getTime();
334
+ if (isNaN(activityTime) || isNaN(dismissTime)) {
335
+ // Invalid timestamp — fail open (include PR to be safe) without
336
+ // permanently removing dismiss record (may be a transient data issue)
337
+ console.error(`[DAILY] Invalid timestamp in PR dismiss check for ${pr.url}, including PR`);
338
+ return true;
339
+ }
340
+ if (activityTime > dismissTime) {
341
+ // New activity after dismiss — auto-undismiss and resurface
342
+ console.error(`[DAILY] Auto-undismissing PR ${pr.url}: new activity at ${pr.updatedAt} after dismiss at ${dismissedAt}`);
343
+ stateManager.undismissIssue(pr.url);
344
+ hasAutoUndismissed = true;
345
+ return true;
346
+ }
347
+ // Still dismissed (last activity is at or before dismiss timestamp)
348
+ return false;
349
+ });
350
+ // Persist auto-undismiss state changes (issue + PR combined into one save)
351
+ if (hasAutoUndismissed) {
352
+ try {
353
+ stateManager.save();
354
+ }
355
+ catch (error) {
356
+ console.error('[DAILY] Failed to persist auto-undismissed state:', errorMessage(error));
357
+ }
358
+ }
333
359
  const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls);
334
360
  digest.summary.totalNeedingAttention = actionableIssues.length;
335
361
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
@@ -88,7 +88,12 @@ export async function fetchDashboardData(token) {
88
88
  digest.autoUnshelvedPRs = [];
89
89
  digest.summary.totalActivePRs = prs.length - freshShelved.length;
90
90
  stateManager.setLastDigest(digest);
91
- stateManager.save();
91
+ try {
92
+ stateManager.save();
93
+ }
94
+ catch (error) {
95
+ console.error('Warning: Failed to save dashboard digest to state:', errorMessage(error));
96
+ }
92
97
  console.error(`Refreshed: ${prs.length} PRs fetched`);
93
98
  return { digest, commentedIssues };
94
99
  }
@@ -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) || [];
@@ -204,7 +204,23 @@ export class PRMonitor {
204
204
  ? this.octokit.repos
205
205
  .getCommit({ owner, repo, ref: ghPR.head.sha })
206
206
  .then((res) => res.data.commit.author?.date)
207
- .catch(() => undefined)
207
+ .catch((err) => {
208
+ // Rate limit errors must propagate — silently swallowing them produces
209
+ // misleading status (e.g. needs_changes when changes were addressed) (#469).
210
+ const status = getHttpStatusCode(err);
211
+ if (status === 429)
212
+ throw err;
213
+ if (status === 403) {
214
+ const msg = errorMessage(err).toLowerCase();
215
+ if (msg.includes('rate limit') || msg.includes('abuse detection'))
216
+ throw err;
217
+ // Non-rate-limit 403 (DMCA, private repo, SSO) — degrade gracefully
218
+ warn('pr-monitor', `403 fetching commit date for ${owner}/${repo}@${ghPR.head.sha.slice(0, 7)}: ${errorMessage(err)}`);
219
+ return undefined;
220
+ }
221
+ warn('pr-monitor', `Failed to fetch commit date for ${owner}/${repo}@${ghPR.head.sha.slice(0, 7)}: ${errorMessage(err)}`);
222
+ return undefined;
223
+ })
208
224
  : Promise.resolve(undefined);
209
225
  const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, latestCommitDate] = await Promise.all([
210
226
  ciPromise,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.42.3",
3
+ "version": "0.42.5",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {