@oss-autopilot/core 0.42.1 → 0.42.3

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.
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Dismiss/Undismiss commands
3
- * Manages dismissing issue reply notifications without posting a comment.
4
- * Dismissed issues resurface automatically when new responses arrive after the dismiss timestamp.
3
+ * Manages dismissing issue and PR notifications without posting a comment.
4
+ * Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
5
5
  */
6
- import { ISSUE_URL_PATTERN } from './validation.js';
7
6
  export interface DismissOutput {
8
7
  dismissed: boolean;
9
8
  url: string;
@@ -12,10 +11,9 @@ export interface UndismissOutput {
12
11
  undismissed: boolean;
13
12
  url: string;
14
13
  }
15
- export { ISSUE_URL_PATTERN };
16
14
  export declare function runDismiss(options: {
17
- issueUrl: string;
15
+ url: string;
18
16
  }): Promise<DismissOutput>;
19
17
  export declare function runUndismiss(options: {
20
- issueUrl: string;
18
+ url: string;
21
19
  }): Promise<UndismissOutput>;
@@ -1,29 +1,27 @@
1
1
  /**
2
2
  * Dismiss/Undismiss commands
3
- * Manages dismissing issue reply notifications without posting a comment.
4
- * Dismissed issues resurface automatically when new responses arrive after the dismiss timestamp.
3
+ * Manages dismissing issue and PR notifications without posting a comment.
4
+ * Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
5
5
  */
6
6
  import { getStateManager } from '../core/index.js';
7
- import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
8
- // Re-export for backward compatibility with tests
9
- export { ISSUE_URL_PATTERN };
7
+ import { ISSUE_OR_PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
10
8
  export async function runDismiss(options) {
11
- validateUrl(options.issueUrl);
12
- validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
9
+ validateUrl(options.url);
10
+ validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
13
11
  const stateManager = getStateManager();
14
- const added = stateManager.dismissIssue(options.issueUrl, new Date().toISOString());
12
+ const added = stateManager.dismissIssue(options.url, new Date().toISOString());
15
13
  if (added) {
16
14
  stateManager.save();
17
15
  }
18
- return { dismissed: added, url: options.issueUrl };
16
+ return { dismissed: added, url: options.url };
19
17
  }
20
18
  export async function runUndismiss(options) {
21
- validateUrl(options.issueUrl);
22
- validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
19
+ validateUrl(options.url);
20
+ validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
23
21
  const stateManager = getStateManager();
24
- const removed = stateManager.undismissIssue(options.issueUrl);
22
+ const removed = stateManager.undismissIssue(options.url);
25
23
  if (removed) {
26
24
  stateManager.save();
27
25
  }
28
- return { undismissed: removed, url: options.issueUrl };
26
+ return { undismissed: removed, url: options.url };
29
27
  }
@@ -5,10 +5,12 @@
5
5
  export declare const PR_URL_PATTERN: RegExp;
6
6
  /** Matches GitHub issue URLs: https://github.com/owner/repo/issues/123 */
7
7
  export declare const ISSUE_URL_PATTERN: RegExp;
8
+ /** Matches GitHub issue or PR URLs: /issues/123 or /pull/123 */
9
+ export declare const ISSUE_OR_PR_URL_PATTERN: RegExp;
8
10
  /**
9
11
  * Validate a GitHub URL against a pattern. Throws if invalid.
10
12
  */
11
- export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: 'PR' | 'issue'): void;
13
+ export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: 'PR' | 'issue' | 'issue or PR'): void;
12
14
  /**
13
15
  * Validate that a URL does not exceed the maximum allowed length.
14
16
  * Returns the URL if valid, throws if too long.
@@ -6,6 +6,8 @@ import { ValidationError } from '../core/errors.js';
6
6
  export const PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;
7
7
  /** Matches GitHub issue URLs: https://github.com/owner/repo/issues/123 */
8
8
  export const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
9
+ /** Matches GitHub issue or PR URLs: /issues/123 or /pull/123 */
10
+ export const ISSUE_OR_PR_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\/\d+$/;
9
11
  /** Maximum allowed URL length */
10
12
  const MAX_URL_LENGTH = 2048;
11
13
  /** Maximum allowed PR/issue number */
@@ -20,8 +22,12 @@ const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
20
22
  export function validateGitHubUrl(url, pattern, entityType) {
21
23
  if (pattern.test(url))
22
24
  return;
23
- const example = entityType === 'PR' ? 'https://github.com/owner/repo/pull/123' : 'https://github.com/owner/repo/issues/123';
24
- throw new ValidationError(`Invalid ${entityType} URL: ${url}. Expected format: ${example}`);
25
+ const examples = {
26
+ PR: 'https://github.com/owner/repo/pull/123',
27
+ issue: 'https://github.com/owner/repo/issues/123',
28
+ 'issue or PR': 'https://github.com/owner/repo/issues/123 or https://github.com/owner/repo/pull/123',
29
+ };
30
+ throw new ValidationError(`Invalid ${entityType} URL: ${url}. Expected format: ${examples[entityType]}`);
25
31
  }
26
32
  /**
27
33
  * Validate that a URL does not exceed the maximum allowed length.
@@ -10,7 +10,7 @@ export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
11
11
  export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, getDashboardPath, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, } from './utils.js';
12
12
  export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
13
- export { enableDebug, isDebugEnabled, debug, warn, timed } from './logger.js';
13
+ export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
14
14
  export { HttpCache, getHttpCache, resetHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
15
15
  export { CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
16
16
  export * from './types.js';
@@ -10,7 +10,7 @@ export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  export { getOctokit, checkRateLimit } from './github.js';
11
11
  export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, getDashboardPath, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, } from './utils.js';
12
12
  export { OssAutopilotError, ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
13
- export { enableDebug, isDebugEnabled, debug, warn, timed } from './logger.js';
13
+ export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
14
14
  export { HttpCache, getHttpCache, resetHttpCache, cachedRequest } from './http-cache.js';
15
15
  export { CRITICAL_STATUSES, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatActionHint, formatBriefSummary, formatSummary, printDigest, } from './daily-logic.js';
16
16
  export * from './types.js';
@@ -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,
@@ -13,7 +13,7 @@ import { getStateManager } from './state.js';
13
13
  import { daysBetween, getDataDir } from './utils.js';
14
14
  import { DEFAULT_CONFIG } from './types.js';
15
15
  import { ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
16
- import { warn } from './logger.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,11 +68,11 @@ 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
  }
@@ -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;
@@ -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,7 +308,7 @@ 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
314
  const errMsg = errorMessage(error);
@@ -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,7 +350,7 @@ 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
356
  const errMsg = errorMessage(error);
@@ -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
  /**
@@ -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
  */
@@ -283,6 +283,11 @@ export class PRMonitor {
283
283
  // If the contributor pushed a commit after the maintainer's comment,
284
284
  // the changes have been addressed — waiting for maintainer re-review
285
285
  if (latestCommitDate && lastMaintainerCommentDate && latestCommitDate > lastMaintainerCommentDate) {
286
+ // Safety net (#431): if a CHANGES_REQUESTED review was submitted after
287
+ // the commit, the maintainer still expects changes — don't mask it
288
+ if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
289
+ return 'needs_response';
290
+ }
286
291
  if (ciStatus === 'failing')
287
292
  return 'failing_ci';
288
293
  return 'changes_addressed';
@@ -101,14 +101,15 @@ export function checkUnrespondedComments(comments, reviews, reviewComments, user
101
101
  if (!review.submitted_at)
102
102
  continue;
103
103
  const body = (review.body || '').trim();
104
- // Include COMMENTED reviews even without body text — they indicate
105
- // inline review comments were posted and may need a response (#151).
106
- // Skip other empty-body reviews (APPROVED, CHANGES_REQUESTED, DISMISSED)
107
- // as those are state changes without comment text.
108
- if (!body && review.state !== 'COMMENTED')
104
+ // Include COMMENTED and CHANGES_REQUESTED reviews even without body text —
105
+ // they indicate inline review comments were posted and need a response (#151, #431).
106
+ // CHANGES_REQUESTED with only inline comments is actionable maintainer feedback.
107
+ // Skip other empty-body reviews (APPROVED, DISMISSED) as those are state changes.
108
+ if (!body && review.state !== 'COMMENTED' && review.state !== 'CHANGES_REQUESTED')
109
109
  continue;
110
110
  const author = review.user?.login || 'unknown';
111
- // For inline-only COMMENTED reviews, skip pure self-replies (#199)
111
+ // For inline-only COMMENTED reviews, skip pure self-replies (#199).
112
+ // CHANGES_REQUESTED reviews are always actionable regardless of self-replies.
112
113
  if (!body && review.state === 'COMMENTED' && review.id != null) {
113
114
  if (isAllSelfReplies(review.id, reviewComments)) {
114
115
  continue;
@@ -117,7 +118,9 @@ export function checkUnrespondedComments(comments, reviews, reviewComments, user
117
118
  // Resolve body: prefer actual text, then inline comment text, then synthetic placeholder
118
119
  const resolvedBody = body ||
119
120
  (review.id != null ? getInlineCommentBody(review.id, reviewComments) : undefined) ||
120
- '(posted inline review comments)';
121
+ (review.state === 'CHANGES_REQUESTED'
122
+ ? '(requested changes via inline review comments)'
123
+ : '(posted inline review comments)');
121
124
  timeline.push({
122
125
  author,
123
126
  body: resolvedBody,
@@ -206,22 +206,22 @@ export declare class StateManager {
206
206
  */
207
207
  isPRShelved(url: string): boolean;
208
208
  /**
209
- * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
209
+ * Dismiss an issue or PR by URL. Dismissed URLs are excluded from `new_response` notifications
210
210
  * until new activity occurs after the dismiss timestamp.
211
- * @param url - The full GitHub issue URL.
212
- * @param timestamp - ISO timestamp of when the issue was dismissed.
211
+ * @param url - The full GitHub issue or PR URL.
212
+ * @param timestamp - ISO timestamp of when the issue/PR was dismissed.
213
213
  * @returns true if newly dismissed, false if already dismissed.
214
214
  */
215
215
  dismissIssue(url: string, timestamp: string): boolean;
216
216
  /**
217
- * Undismiss an issue by URL.
218
- * @param url - The full GitHub issue URL.
217
+ * Undismiss an issue or PR by URL.
218
+ * @param url - The full GitHub issue or PR URL.
219
219
  * @returns true if found and removed, false if not dismissed.
220
220
  */
221
221
  undismissIssue(url: string): boolean;
222
222
  /**
223
- * Get the timestamp when an issue was dismissed.
224
- * @param url - The full GitHub issue URL.
223
+ * Get the timestamp when an issue or PR was dismissed.
224
+ * @param url - The full GitHub issue or PR URL.
225
225
  * @returns The ISO dismiss timestamp, or undefined if not dismissed.
226
226
  */
227
227
  getIssueDismissedAt(url: string): string | undefined;
@@ -721,10 +721,10 @@ export class StateManager {
721
721
  }
722
722
  // === Dismiss / Undismiss Issues ===
723
723
  /**
724
- * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
724
+ * Dismiss an issue or PR by URL. Dismissed URLs are excluded from `new_response` notifications
725
725
  * until new activity occurs after the dismiss timestamp.
726
- * @param url - The full GitHub issue URL.
727
- * @param timestamp - ISO timestamp of when the issue was dismissed.
726
+ * @param url - The full GitHub issue or PR URL.
727
+ * @param timestamp - ISO timestamp of when the issue/PR was dismissed.
728
728
  * @returns true if newly dismissed, false if already dismissed.
729
729
  */
730
730
  dismissIssue(url, timestamp) {
@@ -738,8 +738,8 @@ export class StateManager {
738
738
  return true;
739
739
  }
740
740
  /**
741
- * Undismiss an issue by URL.
742
- * @param url - The full GitHub issue URL.
741
+ * Undismiss an issue or PR by URL.
742
+ * @param url - The full GitHub issue or PR URL.
743
743
  * @returns true if found and removed, false if not dismissed.
744
744
  */
745
745
  undismissIssue(url) {
@@ -750,8 +750,8 @@ export class StateManager {
750
750
  return true;
751
751
  }
752
752
  /**
753
- * Get the timestamp when an issue was dismissed.
754
- * @param url - The full GitHub issue URL.
753
+ * Get the timestamp when an issue or PR was dismissed.
754
+ * @param url - The full GitHub issue or PR URL.
755
755
  * @returns The ISO dismiss timestamp, or undefined if not dismissed.
756
756
  */
757
757
  getIssueDismissedAt(url) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared test factories for oss-autopilot.
3
+ *
4
+ * Centralises mock object construction so that when types gain new required
5
+ * fields we only update one place. Every factory accepts a `Partial<T>`
6
+ * override bag — callers only specify the fields relevant to their test.
7
+ */
8
+ import type { FetchedPR, DailyDigest, ShelvedPRRef, AgentState } from './types.js';
9
+ import type { CapacityAssessment } from '../formatters/json.js';
10
+ export declare function makeFetchedPR(overrides?: Partial<FetchedPR>): FetchedPR;
11
+ export declare function makeDailyDigest(overrides?: Partial<DailyDigest>): DailyDigest;
12
+ export declare function makeShelvedPRRef(overrides?: Partial<ShelvedPRRef>): ShelvedPRRef;
13
+ export declare function makeCapacityAssessment(overrides?: Partial<CapacityAssessment>): CapacityAssessment;
14
+ export declare function makeAgentState(overrides?: Partial<AgentState>): AgentState;