@oss-autopilot/core 0.42.0 → 0.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cli.bundle.cjs +1026 -1018
  2. package/dist/cli.js +18 -30
  3. package/dist/commands/check-integration.js +5 -4
  4. package/dist/commands/comments.js +24 -24
  5. package/dist/commands/daily.d.ts +0 -1
  6. package/dist/commands/daily.js +18 -16
  7. package/dist/commands/dashboard-components.d.ts +33 -0
  8. package/dist/commands/dashboard-components.js +57 -0
  9. package/dist/commands/dashboard-data.js +7 -6
  10. package/dist/commands/dashboard-formatters.d.ts +20 -0
  11. package/dist/commands/dashboard-formatters.js +33 -0
  12. package/dist/commands/dashboard-scripts.d.ts +7 -0
  13. package/dist/commands/dashboard-scripts.js +281 -0
  14. package/dist/commands/dashboard-server.js +3 -2
  15. package/dist/commands/dashboard-styles.d.ts +5 -0
  16. package/dist/commands/dashboard-styles.js +765 -0
  17. package/dist/commands/dashboard-templates.d.ts +6 -18
  18. package/dist/commands/dashboard-templates.js +30 -1134
  19. package/dist/commands/dashboard.js +2 -1
  20. package/dist/commands/dismiss.d.ts +6 -6
  21. package/dist/commands/dismiss.js +13 -13
  22. package/dist/commands/local-repos.js +2 -1
  23. package/dist/commands/parse-list.js +2 -1
  24. package/dist/commands/startup.js +6 -16
  25. package/dist/commands/validation.d.ts +3 -1
  26. package/dist/commands/validation.js +12 -6
  27. package/dist/core/errors.d.ts +9 -0
  28. package/dist/core/errors.js +17 -0
  29. package/dist/core/github-stats.d.ts +14 -21
  30. package/dist/core/github-stats.js +84 -138
  31. package/dist/core/http-cache.d.ts +6 -0
  32. package/dist/core/http-cache.js +16 -4
  33. package/dist/core/index.d.ts +3 -2
  34. package/dist/core/index.js +3 -2
  35. package/dist/core/issue-conversation.js +4 -4
  36. package/dist/core/issue-discovery.d.ts +5 -0
  37. package/dist/core/issue-discovery.js +70 -93
  38. package/dist/core/issue-vetting.js +17 -17
  39. package/dist/core/logger.d.ts +5 -0
  40. package/dist/core/logger.js +8 -0
  41. package/dist/core/pr-monitor.d.ts +6 -20
  42. package/dist/core/pr-monitor.js +16 -52
  43. package/dist/core/review-analysis.js +8 -6
  44. package/dist/core/state.js +4 -5
  45. package/dist/core/test-utils.d.ts +14 -0
  46. package/dist/core/test-utils.js +125 -0
  47. package/dist/core/utils.d.ts +11 -0
  48. package/dist/core/utils.js +21 -0
  49. package/dist/formatters/json.d.ts +0 -1
  50. package/package.json +1 -1
@@ -14,6 +14,7 @@ import * as path from 'path';
14
14
  import * as crypto from 'crypto';
15
15
  import { getCacheDir } from './utils.js';
16
16
  import { debug } from './logger.js';
17
+ import { getHttpStatusCode } from './errors.js';
17
18
  const MODULE = 'http-cache';
18
19
  /**
19
20
  * Maximum age (in ms) before a cache entry is considered stale and eligible for
@@ -44,6 +45,20 @@ export class HttpCache {
44
45
  pathFor(url) {
45
46
  return path.join(this.cacheDir, `${this.keyFor(url)}.json`);
46
47
  }
48
+ /**
49
+ * Return the cached body if the entry exists and is younger than `maxAgeMs`.
50
+ * Useful for time-based caching where ETag validation isn't applicable
51
+ * (e.g., caching aggregated results from paginated API calls).
52
+ */
53
+ getIfFresh(key, maxAgeMs) {
54
+ const entry = this.get(key);
55
+ if (!entry)
56
+ return null;
57
+ const age = Date.now() - new Date(entry.cachedAt).getTime();
58
+ if (!Number.isFinite(age) || age < 0 || age > maxAgeMs)
59
+ return null;
60
+ return entry.body;
61
+ }
47
62
  /**
48
63
  * Look up a cached response. Returns `null` if no cache entry exists.
49
64
  */
@@ -262,8 +277,5 @@ export async function cachedRequest(cache, url, fetcher) {
262
277
  * Octokit throws a RequestError with status 304 for conditional requests.
263
278
  */
264
279
  function isNotModifiedError(err) {
265
- if (err && typeof err === 'object' && 'status' in err) {
266
- return err.status === 304;
267
- }
268
- return false;
280
+ return getHttpStatusCode(err) === 304;
269
281
  }
@@ -8,8 +8,9 @@ export { IssueDiscovery, type IssueCandidate, type SearchPriority, isDocOnlyIssu
8
8
  export { IssueConversationMonitor } from './issue-conversation.js';
9
9
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
11
- export { parseGitHubUrl, daysBetween, splitRepo, getDataDir, getStatePath, getBackupDir, getCacheDir, getDashboardPath, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, } from './utils.js';
12
- export { enableDebug, isDebugEnabled, debug, warn, timed } from './logger.js';
11
+ export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, getDashboardPath, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, } from './utils.js';
12
+ export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
13
+ export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
13
14
  export { HttpCache, getHttpCache, resetHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
14
15
  export { CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
15
16
  export * from './types.js';
@@ -8,8 +8,9 @@ export { IssueDiscovery, isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS, } fro
8
8
  export { IssueConversationMonitor } from './issue-conversation.js';
9
9
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  export { getOctokit, checkRateLimit } from './github.js';
11
- export { parseGitHubUrl, daysBetween, splitRepo, getDataDir, getStatePath, getBackupDir, getCacheDir, getDashboardPath, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, } from './utils.js';
12
- export { enableDebug, isDebugEnabled, debug, warn, timed } from './logger.js';
11
+ export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, getDashboardPath, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, } from './utils.js';
12
+ export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
13
+ export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
13
14
  export { HttpCache, getHttpCache, resetHttpCache, cachedRequest } from './http-cache.js';
14
15
  export { CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
15
16
  export * from './types.js';
@@ -9,9 +9,9 @@ import { getOctokit } from './github.js';
9
9
  import { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  import { paginateAll } from './pagination.js';
11
11
  import { getStateManager } from './state.js';
12
- import { daysBetween, splitRepo, extractOwnerRepo } from './utils.js';
12
+ import { daysBetween, splitRepo, extractOwnerRepo, isOwnRepo } from './utils.js';
13
13
  import { runWorkerPool } from './concurrency.js';
14
- import { ConfigurationError } from './errors.js';
14
+ import { ConfigurationError, errorMessage } from './errors.js';
15
15
  import { debug, warn } from './logger.js';
16
16
  const MODULE = 'issue-conversation';
17
17
  const MAX_CONCURRENT_REQUESTS = 5;
@@ -71,7 +71,7 @@ export class IssueConversationMonitor {
71
71
  const { owner, repo } = parsed;
72
72
  const repoFullName = `${owner}/${repo}`;
73
73
  // Skip issues in user-owned repos (we only care about contributing to others' projects)
74
- if (owner.toLowerCase() === username.toLowerCase())
74
+ if (isOwnRepo(owner, username))
75
75
  continue;
76
76
  // Skip user-authored issues
77
77
  if (item.user?.login?.toLowerCase() === username.toLowerCase())
@@ -107,7 +107,7 @@ export class IssueConversationMonitor {
107
107
  }
108
108
  }
109
109
  catch (error) {
110
- const msg = error instanceof Error ? error.message : String(error);
110
+ const msg = errorMessage(error);
111
111
  failures.push({ issueUrl: item.html_url, error: msg });
112
112
  warn(MODULE, `Error analyzing issue ${item.html_url}: ${msg}`);
113
113
  }
@@ -29,6 +29,11 @@ export declare class IssueDiscovery {
29
29
  * Get starred repos, fetching from GitHub if cache is stale
30
30
  */
31
31
  getStarredReposWithRefresh(): Promise<string[]>;
32
+ /**
33
+ * Shared pipeline for Phases 2 and 3: spam-filter, repo-exclusion, vetting, and star-count filter.
34
+ * Extracts the common logic so each phase only needs to supply search results and context.
35
+ */
36
+ private filterVetAndScore;
32
37
  /**
33
38
  * Search for issues matching our criteria.
34
39
  * Searches in priority order: merged-PR repos first (no label filter), then starred repos,
@@ -12,8 +12,8 @@ import { getOctokit, checkRateLimit } from './github.js';
12
12
  import { getStateManager } from './state.js';
13
13
  import { daysBetween, getDataDir } from './utils.js';
14
14
  import { DEFAULT_CONFIG } from './types.js';
15
- import { ValidationError } from './errors.js';
16
- import { warn } from './logger.js';
15
+ import { ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
16
+ import { debug, info, warn } from './logger.js';
17
17
  import { isDocOnlyIssue, detectLabelFarmingRepos, applyPerRepoCap } from './issue-filtering.js';
18
18
  import { IssueVetter } from './issue-vetting.js';
19
19
  import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.js';
@@ -40,7 +40,7 @@ export class IssueDiscovery {
40
40
  * Updates the state manager with the list and timestamp.
41
41
  */
42
42
  async fetchStarredRepos() {
43
- console.log('Fetching starred repositories...');
43
+ info(MODULE, 'Fetching starred repositories...');
44
44
  const starredRepos = [];
45
45
  try {
46
46
  // Paginate through all starred repos (up to 500 to avoid excessive API calls)
@@ -68,26 +68,26 @@ export class IssueDiscovery {
68
68
  pageCount++;
69
69
  // Limit to 5 pages (500 repos) to avoid excessive API usage
70
70
  if (pageCount >= 5) {
71
- console.log('Reached pagination limit for starred repos (500)');
71
+ info(MODULE, 'Reached pagination limit for starred repos (500)');
72
72
  break;
73
73
  }
74
74
  }
75
- console.log(`Fetched ${starredRepos.length} starred repositories`);
75
+ info(MODULE, `Fetched ${starredRepos.length} starred repositories`);
76
76
  this.stateManager.setStarredRepos(starredRepos);
77
77
  return starredRepos;
78
78
  }
79
79
  catch (error) {
80
80
  const cachedRepos = this.stateManager.getStarredRepos();
81
- const errorMessage = error instanceof Error ? error.message : String(error);
82
- warn(MODULE, 'Error fetching starred repos:', errorMessage);
81
+ const errMsg = errorMessage(error);
82
+ warn(MODULE, 'Error fetching starred repos:', errMsg);
83
83
  if (cachedRepos.length === 0) {
84
84
  warn(MODULE, `Failed to fetch starred repositories from GitHub API. ` +
85
- `No cached repos available. Error: ${errorMessage}\n` +
85
+ `No cached repos available. Error: ${errMsg}\n` +
86
86
  `Tip: Ensure your GITHUB_TOKEN has the 'read:user' scope and try again.`);
87
87
  }
88
88
  else {
89
89
  warn(MODULE, `Failed to fetch starred repositories from GitHub API. ` +
90
- `Using ${cachedRepos.length} cached repos instead. Error: ${errorMessage}`);
90
+ `Using ${cachedRepos.length} cached repos instead. Error: ${errMsg}`);
91
91
  }
92
92
  return cachedRepos;
93
93
  }
@@ -101,6 +101,41 @@ export class IssueDiscovery {
101
101
  }
102
102
  return this.stateManager.getStarredRepos();
103
103
  }
104
+ /**
105
+ * Shared pipeline for Phases 2 and 3: spam-filter, repo-exclusion, vetting, and star-count filter.
106
+ * Extracts the common logic so each phase only needs to supply search results and context.
107
+ */
108
+ async filterVetAndScore(items, filterIssues, excludedRepoSets, remainingNeeded, minStars, phaseLabel) {
109
+ const spamRepos = detectLabelFarmingRepos(items);
110
+ if (spamRepos.size > 0) {
111
+ const spamCount = items.filter((i) => spamRepos.has(i.repository_url.split('/').slice(-2).join('/'))).length;
112
+ debug(MODULE, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(', ')}`);
113
+ }
114
+ const itemsToVet = filterIssues(items)
115
+ .filter((item) => {
116
+ const repoFullName = item.repository_url.split('/').slice(-2).join('/');
117
+ if (spamRepos.has(repoFullName))
118
+ return false;
119
+ return excludedRepoSets.every((s) => !s.has(repoFullName));
120
+ })
121
+ .slice(0, remainingNeeded * 2);
122
+ if (itemsToVet.length === 0) {
123
+ debug(MODULE, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
124
+ return { candidates: [], allVetFailed: false, rateLimitHit: false };
125
+ }
126
+ const { candidates: results, allFailed: allVetFailed, rateLimitHit, } = await this.vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, 'normal');
127
+ const starFiltered = results.filter((c) => {
128
+ if (c.projectHealth.checkFailed)
129
+ return true;
130
+ const stars = c.projectHealth.stargazersCount ?? 0;
131
+ return stars >= minStars;
132
+ });
133
+ const starFilteredCount = results.length - starFiltered.length;
134
+ if (starFilteredCount > 0) {
135
+ debug(MODULE, `[STAR_FILTER] Filtered ${starFilteredCount} ${phaseLabel} candidates below ${minStars} stars`);
136
+ }
137
+ return { candidates: starFiltered, allVetFailed, rateLimitHit };
138
+ }
104
139
  /**
105
140
  * Search for issues matching our criteria.
106
141
  * Searches in priority order: merged-PR repos first (no label filter), then starred repos,
@@ -112,6 +147,7 @@ export class IssueDiscovery {
112
147
  const languages = options.languages || config.languages;
113
148
  const labels = options.labels || config.labels;
114
149
  const maxResults = options.maxResults || 10;
150
+ const minStars = config.minStars ?? 50;
115
151
  const allCandidates = [];
116
152
  let phase0Error = null;
117
153
  let phase1Error = null;
@@ -128,11 +164,11 @@ export class IssueDiscovery {
128
164
  }
129
165
  catch (error) {
130
166
  // Fail fast on auth errors — no point searching with a bad token
131
- if (error?.status === 401) {
167
+ if (getHttpStatusCode(error) === 401) {
132
168
  throw error;
133
169
  }
134
170
  // Non-fatal: proceed with search for transient/network errors
135
- warn(MODULE, 'Could not check rate limit:', error instanceof Error ? error.message : error);
171
+ warn(MODULE, 'Could not check rate limit:', errorMessage(error));
136
172
  }
137
173
  // Get merged-PR repos (highest merge probability)
138
174
  const mergedPRRepos = this.stateManager.getReposWithMergedPRs();
@@ -162,7 +198,7 @@ export class IssueDiscovery {
162
198
  const includeDocIssues = config.includeDocIssues ?? true;
163
199
  const aiBlocklisted = new Set(config.aiPolicyBlocklist ?? DEFAULT_CONFIG.aiPolicyBlocklist ?? []);
164
200
  if (aiBlocklisted.size > 0) {
165
- console.log(`[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(', ')}`);
201
+ debug(MODULE, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(', ')}`);
166
202
  }
167
203
  const filterIssues = (items) => {
168
204
  return items.filter((item) => {
@@ -196,7 +232,7 @@ export class IssueDiscovery {
196
232
  if (phase0Repos.length > 0) {
197
233
  const mergedInPhase0 = Math.min(mergedPRRepos.length, phase0Repos.length);
198
234
  const openInPhase0 = phase0Repos.length - mergedInPhase0;
199
- console.log(`Phase 0: Searching issues in ${phase0Repos.length} repos (${mergedInPhase0} merged-PR, ${openInPhase0} open-PR, no label filter)...`);
235
+ info(MODULE, `Phase 0: Searching issues in ${phase0Repos.length} repos (${mergedInPhase0} merged-PR, ${openInPhase0} open-PR, no label filter)...`);
200
236
  // Phase 0a: merged-PR repos (priority: merged_pr)
201
237
  const mergedPhase0Repos = phase0Repos.slice(0, mergedInPhase0);
202
238
  if (mergedPhase0Repos.length > 0) {
@@ -210,7 +246,7 @@ export class IssueDiscovery {
210
246
  if (rateLimitHit) {
211
247
  rateLimitHitDuringSearch = true;
212
248
  }
213
- console.log(`Found ${mergedCandidates.length} candidates from merged-PR repos`);
249
+ info(MODULE, `Found ${mergedCandidates.length} candidates from merged-PR repos`);
214
250
  }
215
251
  }
216
252
  // Phase 0b: open-PR repos (priority: starred — intermediate tier)
@@ -227,7 +263,7 @@ export class IssueDiscovery {
227
263
  if (rateLimitHit) {
228
264
  rateLimitHitDuringSearch = true;
229
265
  }
230
- console.log(`Found ${openCandidates.length} candidates from open-PR repos`);
266
+ info(MODULE, `Found ${openCandidates.length} candidates from open-PR repos`);
231
267
  }
232
268
  }
233
269
  }
@@ -235,7 +271,7 @@ export class IssueDiscovery {
235
271
  if (allCandidates.length < maxResults && starredRepos.length > 0) {
236
272
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
237
273
  if (reposToSearch.length > 0) {
238
- console.log(`Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
274
+ info(MODULE, `Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
239
275
  const remainingNeeded = maxResults - allCandidates.length;
240
276
  if (remainingNeeded > 0) {
241
277
  const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(reposToSearch.slice(0, 10), baseQuery, remainingNeeded, 'starred', filterIssues);
@@ -246,14 +282,14 @@ export class IssueDiscovery {
246
282
  if (rateLimitHit) {
247
283
  rateLimitHitDuringSearch = true;
248
284
  }
249
- console.log(`Found ${starredCandidates.length} candidates from starred repos`);
285
+ info(MODULE, `Found ${starredCandidates.length} candidates from starred repos`);
250
286
  }
251
287
  }
252
288
  }
253
289
  // Phase 2: General search (if still need more)
254
290
  let phase2Error = null;
255
291
  if (allCandidates.length < maxResults) {
256
- console.log('Phase 2: General issue search...');
292
+ info(MODULE, 'Phase 2: General issue search...');
257
293
  const remainingNeeded = maxResults - allCandidates.length;
258
294
  try {
259
295
  const { data } = await this.octokit.search.issuesAndPullRequests({
@@ -262,40 +298,9 @@ export class IssueDiscovery {
262
298
  order: 'desc',
263
299
  per_page: remainingNeeded * 3, // Fetch extra since some will be filtered
264
300
  });
265
- console.log(`Found ${data.total_count} issues in general search, processing top ${data.items.length}...`);
266
- // Detect and filter label-farming repos (#97)
267
- const spamRepos = detectLabelFarmingRepos(data.items);
268
- if (spamRepos.size > 0) {
269
- const spamCount = data.items.filter((i) => spamRepos.has(i.repository_url.split('/').slice(-2).join('/'))).length;
270
- console.log(`[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(', ')}`);
271
- }
272
- // Filter and exclude already-found repos
301
+ info(MODULE, `Found ${data.total_count} issues in general search, processing top ${data.items.length}...`);
273
302
  const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
274
- const itemsToVet = filterIssues(data.items)
275
- .filter((item) => {
276
- const repoFullName = item.repository_url.split('/').slice(-2).join('/');
277
- return !spamRepos.has(repoFullName);
278
- })
279
- .filter((item) => {
280
- const repoFullName = item.repository_url.split('/').slice(-2).join('/');
281
- // Skip if already searched in earlier phases
282
- return (!phase0RepoSet.has(repoFullName) && !starredRepoSet.has(repoFullName) && !seenRepos.has(repoFullName));
283
- })
284
- .slice(0, remainingNeeded * 2);
285
- const { candidates: results, allFailed: allVetFailed, rateLimitHit: vetRateLimitHit, } = await this.vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, 'normal');
286
- // Apply minStars filter to Phase 2 results (#105)
287
- // Phase 0/1 are exempt — if you've already contributed to or starred a repo, star count is irrelevant.
288
- const minStars = config.minStars ?? 50;
289
- const starFiltered = results.filter((c) => {
290
- if (c.projectHealth.checkFailed)
291
- return true; // Don't penalize repos we couldn't check
292
- const stars = c.projectHealth.stargazersCount ?? 0;
293
- return stars >= minStars;
294
- });
295
- const starFilteredCount = results.length - starFiltered.length;
296
- if (starFilteredCount > 0) {
297
- console.log(`[STAR_FILTER] Filtered ${starFilteredCount} candidates below ${minStars} stars`);
298
- }
303
+ const { candidates: starFiltered, allVetFailed, rateLimitHit: vetRateLimitHit, } = await this.filterVetAndScore(data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], remainingNeeded, minStars, 'Phase 2');
299
304
  allCandidates.push(...starFiltered);
300
305
  if (allVetFailed) {
301
306
  phase2Error = (phase2Error ? phase2Error + '; ' : '') + 'all vetting failed';
@@ -303,15 +308,15 @@ export class IssueDiscovery {
303
308
  if (vetRateLimitHit) {
304
309
  rateLimitHitDuringSearch = true;
305
310
  }
306
- console.log(`Found ${starFiltered.length} candidates from general search`);
311
+ info(MODULE, `Found ${starFiltered.length} candidates from general search`);
307
312
  }
308
313
  catch (error) {
309
- const errorMessage = error instanceof Error ? error.message : String(error);
310
- phase2Error = errorMessage;
314
+ const errMsg = errorMessage(error);
315
+ phase2Error = errMsg;
311
316
  if (IssueVetter.isRateLimitError(error)) {
312
317
  rateLimitHitDuringSearch = true;
313
318
  }
314
- warn(MODULE, `Error in general issue search: ${errorMessage}`);
319
+ warn(MODULE, `Error in general issue search: ${errMsg}`);
315
320
  }
316
321
  }
317
322
  // Phase 3: Actively maintained repos (#349)
@@ -320,13 +325,12 @@ export class IssueDiscovery {
320
325
  // Uses label-free query to cast a wider net focused on repo health.
321
326
  let phase3Error = null;
322
327
  if (allCandidates.length < maxResults) {
323
- console.log('Phase 3: Searching actively maintained repos...');
328
+ info(MODULE, 'Phase 3: Searching actively maintained repos...');
324
329
  const remainingNeeded = maxResults - allCandidates.length;
325
330
  const thirtyDaysAgo = new Date();
326
331
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
327
332
  const pushedSince = thirtyDaysAgo.toISOString().split('T')[0];
328
- const phase3MinStars = config.minStars ?? 50;
329
- const phase3Query = `is:issue is:open no:assignee ${langQuery} stars:>=${phase3MinStars} pushed:>=${pushedSince} archived:false`
333
+ const phase3Query = `is:issue is:open no:assignee ${langQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
330
334
  .replace(/ +/g, ' ')
331
335
  .trim();
332
336
  try {
@@ -336,36 +340,9 @@ export class IssueDiscovery {
336
340
  order: 'desc',
337
341
  per_page: remainingNeeded * 3,
338
342
  });
339
- console.log(`Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
340
- // Filter spam, already-found repos, and already-searched repos
341
- const spamRepos = detectLabelFarmingRepos(data.items);
342
- if (spamRepos.size > 0) {
343
- const spamCount = data.items.filter((i) => spamRepos.has(i.repository_url.split('/').slice(-2).join('/'))).length;
344
- console.log(`[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(', ')}`);
345
- }
343
+ info(MODULE, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
346
344
  const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
347
- const itemsToVet = filterIssues(data.items)
348
- .filter((item) => {
349
- const repoFullName = item.repository_url.split('/').slice(-2).join('/');
350
- return (!spamRepos.has(repoFullName) &&
351
- !phase0RepoSet.has(repoFullName) &&
352
- !starredRepoSet.has(repoFullName) &&
353
- !seenRepos.has(repoFullName));
354
- })
355
- .slice(0, remainingNeeded * 2);
356
- const { candidates: results, allFailed: allVetFailed, rateLimitHit: vetRateLimitHit, } = await this.vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, 'normal');
357
- // Apply minStars filter (same as Phase 2, #105)
358
- const minStars = config.minStars ?? 50;
359
- const starFiltered = results.filter((c) => {
360
- if (c.projectHealth.checkFailed)
361
- return true;
362
- const stars = c.projectHealth.stargazersCount ?? 0;
363
- return stars >= minStars;
364
- });
365
- const starFilteredCount = results.length - starFiltered.length;
366
- if (starFilteredCount > 0) {
367
- console.log(`[STAR_FILTER] Filtered ${starFilteredCount} Phase 3 candidates below ${minStars} stars`);
368
- }
345
+ const { candidates: starFiltered, allVetFailed, rateLimitHit: vetRateLimitHit, } = await this.filterVetAndScore(data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], remainingNeeded, minStars, 'Phase 3');
369
346
  allCandidates.push(...starFiltered);
370
347
  if (allVetFailed) {
371
348
  phase3Error = 'all vetting failed';
@@ -373,15 +350,15 @@ export class IssueDiscovery {
373
350
  if (vetRateLimitHit) {
374
351
  rateLimitHitDuringSearch = true;
375
352
  }
376
- console.log(`Found ${starFiltered.length} candidates from maintained-repo search`);
353
+ info(MODULE, `Found ${starFiltered.length} candidates from maintained-repo search`);
377
354
  }
378
355
  catch (error) {
379
- const errorMessage = error instanceof Error ? error.message : String(error);
380
- phase3Error = errorMessage;
356
+ const errMsg = errorMessage(error);
357
+ phase3Error = errMsg;
381
358
  if (IssueVetter.isRateLimitError(error)) {
382
359
  rateLimitHitDuringSearch = true;
383
360
  }
384
- warn(MODULE, `Error in maintained-repo search: ${errorMessage}`);
361
+ warn(MODULE, `Error in maintained-repo search: ${errMsg}`);
385
362
  }
386
363
  }
387
364
  if (allCandidates.length === 0) {
@@ -476,7 +453,7 @@ export class IssueDiscovery {
476
453
  rateLimitFailures++;
477
454
  }
478
455
  const batchRepos = batch.join(', ');
479
- warn(MODULE, `Error searching issues in batch [${batchRepos}]:`, error instanceof Error ? error.message : error);
456
+ warn(MODULE, `Error searching issues in batch [${batchRepos}]:`, errorMessage(error));
480
457
  }
481
458
  }
482
459
  const allBatchesFailed = failedBatches === batches.length && batches.length > 0;
@@ -553,7 +530,7 @@ export class IssueDiscovery {
553
530
  content += `- **Score**: Viability score (0-100)\n`;
554
531
  content += `- **Recommendation**: Y = approve, N = skip, ? = needs_review\n`;
555
532
  fs.writeFileSync(outputFile, content, 'utf-8');
556
- console.log(`Saved ${sorted.length} issues to ${outputFile}`);
533
+ info(MODULE, `Saved ${sorted.length} issues to ${outputFile}`);
557
534
  return outputFile;
558
535
  }
559
536
  /**
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { paginateAll } from './pagination.js';
8
8
  import { parseGitHubUrl, daysBetween } from './utils.js';
9
- import { ValidationError } from './errors.js';
9
+ import { ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
10
10
  import { warn } from './logger.js';
11
11
  import { getHttpCache, cachedRequest } from './http-cache.js';
12
12
  import { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
@@ -254,7 +254,7 @@ export class IssueVetter {
254
254
  if (IssueVetter.isRateLimitError(error)) {
255
255
  rateLimitFailures++;
256
256
  }
257
- warn(MODULE, `Error vetting issue ${url}:`, error instanceof Error ? error.message : error);
257
+ warn(MODULE, `Error vetting issue ${url}:`, errorMessage(error));
258
258
  });
259
259
  pending.push(task);
260
260
  // Limit concurrency
@@ -275,11 +275,11 @@ export class IssueVetter {
275
275
  }
276
276
  /** Check if an error is a GitHub rate limit error (429 or rate-limit 403). */
277
277
  static isRateLimitError(error) {
278
- const status = error?.status;
278
+ const status = getHttpStatusCode(error);
279
279
  if (status === 429)
280
280
  return true;
281
281
  if (status === 403) {
282
- const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
282
+ const msg = errorMessage(error).toLowerCase();
283
283
  return msg.includes('rate limit');
284
284
  }
285
285
  return false;
@@ -306,9 +306,9 @@ export class IssueVetter {
306
306
  return { passed: data.total_count === 0 && linkedPRs.length === 0 };
307
307
  }
308
308
  catch (error) {
309
- const errorMessage = error instanceof Error ? error.message : String(error);
310
- warn(MODULE, `Failed to check for existing PRs on ${owner}/${repo}#${issueNumber}: ${errorMessage}. Assuming no existing PR.`);
311
- return { passed: true, inconclusive: true, reason: errorMessage };
309
+ const errMsg = errorMessage(error);
310
+ warn(MODULE, `Failed to check for existing PRs on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming no existing PR.`);
311
+ return { passed: true, inconclusive: true, reason: errMsg };
312
312
  }
313
313
  }
314
314
  /**
@@ -325,8 +325,8 @@ export class IssueVetter {
325
325
  return data.total_count;
326
326
  }
327
327
  catch (error) {
328
- const errorMessage = error instanceof Error ? error.message : String(error);
329
- warn(MODULE, `Could not check merged PRs in ${owner}/${repo}: ${errorMessage}. Defaulting to 0.`);
328
+ const errMsg = errorMessage(error);
329
+ warn(MODULE, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Defaulting to 0.`);
330
330
  return 0;
331
331
  }
332
332
  }
@@ -370,9 +370,9 @@ export class IssueVetter {
370
370
  return { passed: true };
371
371
  }
372
372
  catch (error) {
373
- const errorMessage = error instanceof Error ? error.message : String(error);
374
- warn(MODULE, `Failed to check claim status on ${owner}/${repo}#${issueNumber}: ${errorMessage}. Assuming not claimed.`);
375
- return { passed: true, inconclusive: true, reason: errorMessage };
373
+ const errMsg = errorMessage(error);
374
+ warn(MODULE, `Failed to check claim status on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming not claimed.`);
375
+ return { passed: true, inconclusive: true, reason: errMsg };
376
376
  }
377
377
  }
378
378
  async checkProjectHealth(owner, repo) {
@@ -403,8 +403,8 @@ export class IssueVetter {
403
403
  }
404
404
  }
405
405
  catch (error) {
406
- const errorMessage = error instanceof Error ? error.message : String(error);
407
- warn(MODULE, `Failed to check CI status for ${owner}/${repo}: ${errorMessage}. Defaulting to unknown.`);
406
+ const errMsg = errorMessage(error);
407
+ warn(MODULE, `Failed to check CI status for ${owner}/${repo}: ${errMsg}. Defaulting to unknown.`);
408
408
  }
409
409
  return {
410
410
  repo: `${owner}/${repo}`,
@@ -419,8 +419,8 @@ export class IssueVetter {
419
419
  };
420
420
  }
421
421
  catch (error) {
422
- const errorMessage = error instanceof Error ? error.message : String(error);
423
- warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errorMessage}`);
422
+ const errMsg = errorMessage(error);
423
+ warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
424
424
  return {
425
425
  repo: `${owner}/${repo}`,
426
426
  lastCommitAt: '',
@@ -430,7 +430,7 @@ export class IssueVetter {
430
430
  ciStatus: 'unknown',
431
431
  isActive: false,
432
432
  checkFailed: true,
433
- failureReason: errorMessage,
433
+ failureReason: errMsg,
434
434
  };
435
435
  }
436
436
  }
@@ -11,6 +11,11 @@ export declare function isDebugEnabled(): boolean;
11
11
  * Log a debug message. Only outputs when --debug is enabled.
12
12
  */
13
13
  export declare function debug(module: string, message: string, ...args: unknown[]): void;
14
+ /**
15
+ * Log an informational message. Always outputs to stderr.
16
+ * Use for user-facing progress indicators during long-running operations.
17
+ */
18
+ export declare function info(module: string, message: string, ...args: unknown[]): void;
14
19
  /**
15
20
  * Log a warning. Always outputs.
16
21
  */
@@ -21,6 +21,14 @@ export function debug(module, message, ...args) {
21
21
  const timestamp = new Date().toISOString();
22
22
  console.error(`[${timestamp}] [DEBUG] [${module}] ${message}`, ...args);
23
23
  }
24
+ /**
25
+ * Log an informational message. Always outputs to stderr.
26
+ * Use for user-facing progress indicators during long-running operations.
27
+ */
28
+ export function info(module, message, ...args) {
29
+ const timestamp = new Date().toISOString();
30
+ console.error(`[${timestamp}] [INFO] [${module}] ${message}`, ...args);
31
+ }
24
32
  /**
25
33
  * Log a warning. Always outputs.
26
34
  */
@@ -12,6 +12,7 @@
12
12
  * - github-stats.ts: Merged/closed PR counts and star fetching
13
13
  */
14
14
  import { FetchedPR, DailyDigest, ClosedPR, MergedPR } from './types.js';
15
+ import { type PRCountsResult } from './github-stats.js';
15
16
  export { computeDisplayLabel } from './display-utils.js';
16
17
  export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
17
18
  export { isConditionalChecklistItem } from './checklist-analysis.js';
@@ -59,35 +60,20 @@ export declare class PRMonitor {
59
60
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
60
61
  * Delegates to github-stats module.
61
62
  */
62
- fetchUserMergedPRCounts(): Promise<{
63
- repos: Map<string, {
64
- count: number;
65
- lastMergedAt: string;
66
- }>;
67
- monthlyCounts: Record<string, number>;
68
- monthlyOpenedCounts: Record<string, number>;
69
- dailyActivityCounts: Record<string, number>;
70
- }>;
63
+ fetchUserMergedPRCounts(): Promise<PRCountsResult<{
64
+ count: number;
65
+ lastMergedAt: string;
66
+ }>>;
71
67
  /**
72
68
  * Fetch closed-without-merge PR counts per repository for the configured user.
73
69
  * Delegates to github-stats module.
74
70
  */
75
- fetchUserClosedPRCounts(): Promise<{
76
- repos: Map<string, number>;
77
- monthlyCounts: Record<string, number>;
78
- monthlyOpenedCounts: Record<string, number>;
79
- dailyActivityCounts: Record<string, number>;
80
- }>;
71
+ fetchUserClosedPRCounts(): Promise<PRCountsResult<number>>;
81
72
  /**
82
73
  * Fetch GitHub star counts for a list of repositories.
83
74
  * Delegates to github-stats module.
84
75
  */
85
76
  fetchRepoStarCounts(repos: string[]): Promise<Map<string, number>>;
86
- /**
87
- * Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
88
- * Returns parsed search results that pass all filters.
89
- */
90
- private fetchRecentPRs;
91
77
  /**
92
78
  * Fetch PRs closed without merge in the last N days.
93
79
  * Delegates to github-stats module.