@oss-autopilot/core 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli.bundle.cjs +63 -63
  2. package/dist/commands/comments.js +0 -1
  3. package/dist/commands/config.js +45 -5
  4. package/dist/commands/daily.js +190 -157
  5. package/dist/commands/dashboard-data.js +37 -30
  6. package/dist/commands/dashboard-server.js +0 -1
  7. package/dist/commands/dismiss.js +0 -6
  8. package/dist/commands/init.js +0 -1
  9. package/dist/commands/local-repos.js +1 -2
  10. package/dist/commands/move.js +12 -11
  11. package/dist/commands/setup.d.ts +2 -1
  12. package/dist/commands/setup.js +166 -130
  13. package/dist/commands/shelve.js +10 -10
  14. package/dist/commands/startup.js +30 -14
  15. package/dist/core/ci-analysis.d.ts +6 -0
  16. package/dist/core/ci-analysis.js +89 -12
  17. package/dist/core/daily-logic.js +24 -33
  18. package/dist/core/index.d.ts +2 -1
  19. package/dist/core/index.js +2 -1
  20. package/dist/core/issue-discovery.d.ts +7 -44
  21. package/dist/core/issue-discovery.js +83 -188
  22. package/dist/core/issue-eligibility.d.ts +35 -0
  23. package/dist/core/issue-eligibility.js +126 -0
  24. package/dist/core/issue-vetting.d.ts +6 -21
  25. package/dist/core/issue-vetting.js +15 -279
  26. package/dist/core/pr-monitor.d.ts +7 -12
  27. package/dist/core/pr-monitor.js +14 -80
  28. package/dist/core/repo-health.d.ts +24 -0
  29. package/dist/core/repo-health.js +193 -0
  30. package/dist/core/search-phases.d.ts +55 -0
  31. package/dist/core/search-phases.js +155 -0
  32. package/dist/core/state.d.ts +11 -0
  33. package/dist/core/state.js +63 -4
  34. package/dist/core/types.d.ts +8 -1
  35. package/dist/core/types.js +7 -0
  36. package/dist/formatters/json.d.ts +1 -1
  37. package/package.json +1 -1
@@ -52,43 +52,34 @@ export function applyStatusOverrides(prs, state) {
52
52
  if (!overrides || Object.keys(overrides).length === 0)
53
53
  return prs;
54
54
  const stateManager = getStateManager();
55
- // Snapshot keys before iteration clearStatusOverride mutates the same object
56
- const overrideUrls = new Set(Object.keys(overrides));
57
- let didAutoClear = false;
58
- const result = prs.map((pr) => {
59
- try {
60
- const override = stateManager.getStatusOverride(pr.url, pr.updatedAt);
61
- if (!override) {
62
- if (overrideUrls.has(pr.url))
63
- didAutoClear = true;
64
- return pr;
65
- }
66
- if (!VALID_OVERRIDE_STATUSES.has(override.status)) {
67
- warn('daily-logic', `Invalid override status "${override.status}" for ${pr.url} — ignoring`);
68
- return pr;
55
+ // Wrap in batch: getStatusOverride may auto-clear stale overrides via clearStatusOverride,
56
+ // which now auto-saves. Batching produces a single disk write for all auto-clears.
57
+ let result = [];
58
+ stateManager.batch(() => {
59
+ result = prs.map((pr) => {
60
+ try {
61
+ const override = stateManager.getStatusOverride(pr.url, pr.updatedAt);
62
+ if (!override) {
63
+ return pr;
64
+ }
65
+ if (!VALID_OVERRIDE_STATUSES.has(override.status)) {
66
+ warn('daily-logic', `Invalid override status "${override.status}" for ${pr.url} — ignoring`);
67
+ return pr;
68
+ }
69
+ if (override.status === pr.status)
70
+ return pr;
71
+ // Clear the contradictory reason field and set an appropriate default
72
+ if (override.status === 'waiting_on_maintainer') {
73
+ return { ...pr, status: override.status, actionReason: undefined, waitReason: 'pending_review' };
74
+ }
75
+ return { ...pr, status: override.status, waitReason: undefined, actionReason: 'needs_response' };
69
76
  }
70
- if (override.status === pr.status)
77
+ catch (err) {
78
+ warn('daily-logic', `Failed to apply status override for ${pr.url}: ${errorMessage(err)}`);
71
79
  return pr;
72
- // Clear the contradictory reason field and set an appropriate default
73
- if (override.status === 'waiting_on_maintainer') {
74
- return { ...pr, status: override.status, actionReason: undefined, waitReason: 'pending_review' };
75
80
  }
76
- return { ...pr, status: override.status, waitReason: undefined, actionReason: 'needs_response' };
77
- }
78
- catch (err) {
79
- warn('daily-logic', `Failed to apply status override for ${pr.url}: ${errorMessage(err)}`);
80
- return pr;
81
- }
81
+ });
82
82
  });
83
- // Persist any auto-cleared overrides so they don't resurrect on restart
84
- if (didAutoClear) {
85
- try {
86
- stateManager.save();
87
- }
88
- catch (err) {
89
- warn('daily-logic', `Failed to persist auto-cleared overrides — they may reappear on restart: ${errorMessage(err)}`);
90
- }
91
- }
92
83
  return result;
93
84
  }
94
85
  // ---------------------------------------------------------------------------
@@ -4,7 +4,8 @@
4
4
  */
5
5
  export { StateManager, getStateManager, resetStateManager, type Stats } from './state.js';
6
6
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
7
- export { IssueDiscovery, type IssueCandidate, type SearchPriority, isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS, } from './issue-discovery.js';
7
+ export { IssueDiscovery } from './issue-discovery.js';
8
+ export { isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS } from './issue-filtering.js';
8
9
  export { IssueConversationMonitor } from './issue-conversation.js';
9
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
11
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
@@ -4,7 +4,8 @@
4
4
  */
5
5
  export { StateManager, getStateManager, resetStateManager } from './state.js';
6
6
  export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
7
- export { IssueDiscovery, isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS, } from './issue-discovery.js';
7
+ export { IssueDiscovery } from './issue-discovery.js';
8
+ export { isDocOnlyIssue, applyPerRepoCap, DOC_ONLY_LABELS } from './issue-filtering.js';
8
9
  export { IssueConversationMonitor } from './issue-conversation.js';
9
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
11
  export { getOctokit, checkRateLimit } from './github.js';
@@ -1,17 +1,15 @@
1
1
  /**
2
2
  * Issue Discovery — orchestrates multi-phase issue search across GitHub.
3
3
  *
4
- * Delegates filtering, scoring, and vetting to focused modules (#356):
5
- * - issue-filtering.ts — spam detection, doc-only filtering, per-repo caps
6
- * - issue-scoring.ts — viability scores, repo quality bonuses
7
- * - issue-vetting.ts individual issue checks (PRs, claims, health, guidelines)
4
+ * Delegates filtering, scoring, vetting, and search infrastructure to focused modules (#356, #621):
5
+ * - issue-filtering.ts — spam detection, doc-only filtering, per-repo caps
6
+ * - issue-scoring.ts — viability scores, repo quality bonuses
7
+ * - issue-vetting.ts vetting orchestration, recommendation + viability scoring
8
+ * - issue-eligibility.ts — PR existence, claim detection, requirements analysis
9
+ * - repo-health.ts — project health checks, contribution guidelines
10
+ * - search-phases.ts — search helpers, caching, batched repo search
8
11
  */
9
12
  import { type IssueCandidate } from './types.js';
10
- import { type ViabilityScoreParams } from './issue-scoring.js';
11
- export { isDocOnlyIssue, applyPerRepoCap, isLabelFarming, hasTemplatedTitle, detectLabelFarmingRepos, DOC_ONLY_LABELS, BEGINNER_LABELS, type GitHubSearchItem, } from './issue-filtering.js';
12
- export { calculateRepoQualityBonus, calculateViabilityScore, type ViabilityScoreParams } from './issue-scoring.js';
13
- export { type CheckResult } from './issue-vetting.js';
14
- export type { SearchPriority, IssueCandidate } from './types.js';
15
13
  export declare class IssueDiscovery {
16
14
  private octokit;
17
15
  private stateManager;
@@ -20,12 +18,6 @@ export declare class IssueDiscovery {
20
18
  /** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
21
19
  rateLimitWarning: string | null;
22
20
  constructor(githubToken: string);
23
- /**
24
- * Wrap octokit.search.issuesAndPullRequests with time-based caching.
25
- * Repeated identical queries within SEARCH_CACHE_TTL_MS return cached results
26
- * without consuming GitHub API rate limit points.
27
- */
28
- private cachedSearch;
29
21
  /**
30
22
  * Fetch the authenticated user's starred repositories from GitHub.
31
23
  * Updates the state manager with the list and timestamp.
@@ -35,11 +27,6 @@ export declare class IssueDiscovery {
35
27
  * Get starred repos, fetching from GitHub if cache is stale
36
28
  */
37
29
  getStarredReposWithRefresh(): Promise<string[]>;
38
- /**
39
- * Shared pipeline for Phases 2 and 3: spam-filter, repo-exclusion, vetting, and star-count filter.
40
- * Extracts the common logic so each phase only needs to supply search results and context.
41
- */
42
- private filterVetAndScore;
43
30
  /**
44
31
  * Search for issues matching our criteria.
45
32
  * Searches in priority order: merged-PR repos first (no label filter), then starred repos,
@@ -51,34 +38,10 @@ export declare class IssueDiscovery {
51
38
  labels?: string[];
52
39
  maxResults?: number;
53
40
  }): Promise<IssueCandidate[]>;
54
- /**
55
- * Search for issues within specific repos using batched queries.
56
- *
57
- * To avoid GitHub's secondary rate limit (30 requests/minute), we batch
58
- * multiple repos into a single search query using OR syntax:
59
- * repo:owner1/repo1 OR repo:owner2/repo2 OR repo:owner3/repo3
60
- *
61
- * This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE).
62
- */
63
- private searchInRepos;
64
- /**
65
- * Split repos into batches of the specified size.
66
- */
67
- private batchRepos;
68
41
  /**
69
42
  * Vet a specific issue (delegates to IssueVetter).
70
43
  */
71
44
  vetIssue(issueUrl: string): Promise<IssueCandidate>;
72
- /**
73
- * Analyze issue requirements for clarity (delegates to IssueVetter).
74
- * Kept on class for backward compatibility.
75
- */
76
- analyzeRequirements(body: string): boolean;
77
- /**
78
- * Calculate viability score for an issue (delegates to issue-scoring module).
79
- * Kept on class for backward compatibility with tests that call instance.calculateViabilityScore().
80
- */
81
- calculateViabilityScore(params: ViabilityScoreParams): number;
82
45
  /**
83
46
  * Save search results to ~/.oss-autopilot/found-issues.md
84
47
  * Results are sorted by viability score (highest first)
@@ -1,31 +1,27 @@
1
1
  /**
2
2
  * Issue Discovery — orchestrates multi-phase issue search across GitHub.
3
3
  *
4
- * Delegates filtering, scoring, and vetting to focused modules (#356):
5
- * - issue-filtering.ts — spam detection, doc-only filtering, per-repo caps
6
- * - issue-scoring.ts — viability scores, repo quality bonuses
7
- * - issue-vetting.ts individual issue checks (PRs, claims, health, guidelines)
4
+ * Delegates filtering, scoring, vetting, and search infrastructure to focused modules (#356, #621):
5
+ * - issue-filtering.ts — spam detection, doc-only filtering, per-repo caps
6
+ * - issue-scoring.ts — viability scores, repo quality bonuses
7
+ * - issue-vetting.ts vetting orchestration, recommendation + viability scoring
8
+ * - issue-eligibility.ts — PR existence, claim detection, requirements analysis
9
+ * - repo-health.ts — project health checks, contribution guidelines
10
+ * - search-phases.ts — search helpers, caching, batched repo search
8
11
  */
9
12
  import * as fs from 'fs';
10
13
  import * as path from 'path';
11
14
  import { getOctokit, checkRateLimit } from './github.js';
12
15
  import { getStateManager } from './state.js';
13
16
  import { daysBetween, getDataDir } from './utils.js';
14
- import { DEFAULT_CONFIG } from './types.js';
17
+ import { DEFAULT_CONFIG, SCOPE_LABELS } from './types.js';
15
18
  import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError } from './errors.js';
16
19
  import { debug, info, warn } from './logger.js';
17
- import { getHttpCache, cachedTimeBased } from './http-cache.js';
18
- import { isDocOnlyIssue, detectLabelFarmingRepos, applyPerRepoCap } from './issue-filtering.js';
20
+ import { isDocOnlyIssue, applyPerRepoCap } from './issue-filtering.js';
19
21
  import { IssueVetter } from './issue-vetting.js';
20
- import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.js';
21
22
  import { getTopicsForCategories } from './category-mapping.js';
22
- // Re-export everything from sub-modules for backward compatibility.
23
- // Existing consumers (tests, CLI commands) import from './issue-discovery.js'.
24
- export { isDocOnlyIssue, applyPerRepoCap, isLabelFarming, hasTemplatedTitle, detectLabelFarmingRepos, DOC_ONLY_LABELS, BEGINNER_LABELS, } from './issue-filtering.js';
25
- export { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
23
+ import { buildLabelQuery, buildEffectiveLabels, interleaveArrays, cachedSearchIssues, filterVetAndScore, searchInRepos, } from './search-phases.js';
26
24
  const MODULE = 'issue-discovery';
27
- /** TTL for cached search API results (15 minutes). */
28
- const SEARCH_CACHE_TTL_MS = 15 * 60 * 1000;
29
25
  export class IssueDiscovery {
30
26
  octokit;
31
27
  stateManager;
@@ -39,18 +35,6 @@ export class IssueDiscovery {
39
35
  this.stateManager = getStateManager();
40
36
  this.vetter = new IssueVetter(this.octokit, this.stateManager);
41
37
  }
42
- /**
43
- * Wrap octokit.search.issuesAndPullRequests with time-based caching.
44
- * Repeated identical queries within SEARCH_CACHE_TTL_MS return cached results
45
- * without consuming GitHub API rate limit points.
46
- */
47
- async cachedSearch(params) {
48
- const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
49
- return cachedTimeBased(getHttpCache(), cacheKey, SEARCH_CACHE_TTL_MS, async () => {
50
- const { data } = await this.octokit.search.issuesAndPullRequests(params);
51
- return data;
52
- });
53
- }
54
38
  /**
55
39
  * Fetch the authenticated user's starred repositories from GitHub.
56
40
  * Updates the state manager with the list and timestamp.
@@ -117,41 +101,6 @@ export class IssueDiscovery {
117
101
  }
118
102
  return this.stateManager.getStarredRepos();
119
103
  }
120
- /**
121
- * Shared pipeline for Phases 2 and 3: spam-filter, repo-exclusion, vetting, and star-count filter.
122
- * Extracts the common logic so each phase only needs to supply search results and context.
123
- */
124
- async filterVetAndScore(items, filterIssues, excludedRepoSets, remainingNeeded, minStars, phaseLabel) {
125
- const spamRepos = detectLabelFarmingRepos(items);
126
- if (spamRepos.size > 0) {
127
- const spamCount = items.filter((i) => spamRepos.has(i.repository_url.split('/').slice(-2).join('/'))).length;
128
- debug(MODULE, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(', ')}`);
129
- }
130
- const itemsToVet = filterIssues(items)
131
- .filter((item) => {
132
- const repoFullName = item.repository_url.split('/').slice(-2).join('/');
133
- if (spamRepos.has(repoFullName))
134
- return false;
135
- return excludedRepoSets.every((s) => !s.has(repoFullName));
136
- })
137
- .slice(0, remainingNeeded * 2);
138
- if (itemsToVet.length === 0) {
139
- debug(MODULE, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
140
- return { candidates: [], allVetFailed: false, rateLimitHit: false };
141
- }
142
- const { candidates: results, allFailed: allVetFailed, rateLimitHit, } = await this.vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, 'normal');
143
- const starFiltered = results.filter((c) => {
144
- if (c.projectHealth.checkFailed)
145
- return true;
146
- const stars = c.projectHealth.stargazersCount ?? 0;
147
- return stars >= minStars;
148
- });
149
- const starFilteredCount = results.length - starFiltered.length;
150
- if (starFilteredCount > 0) {
151
- debug(MODULE, `[STAR_FILTER] Filtered ${starFilteredCount} ${phaseLabel} candidates below ${minStars} stars`);
152
- }
153
- return { candidates: starFiltered, allVetFailed, rateLimitHit };
154
- }
155
104
  /**
156
105
  * Search for issues matching our criteria.
157
106
  * Searches in priority order: merged-PR repos first (no label filter), then starred repos,
@@ -161,7 +110,8 @@ export class IssueDiscovery {
161
110
  async searchIssues(options = {}) {
162
111
  const config = this.stateManager.getState().config;
163
112
  const languages = options.languages || config.languages;
164
- const labels = options.labels || config.labels;
113
+ const scopes = config.scope; // undefined = legacy mode
114
+ const labels = options.labels || (scopes ? buildEffectiveLabels(scopes, config.labels) : config.labels);
165
115
  const maxResults = options.maxResults || 10;
166
116
  const minStars = config.minStars ?? 50;
167
117
  const allCandidates = [];
@@ -202,7 +152,7 @@ export class IssueDiscovery {
202
152
  const maxAgeDays = config.maxIssueAgeDays || 90;
203
153
  const now = new Date();
204
154
  // Build query parts
205
- const labelQuery = labels.map((l) => `label:"${l}"`).join(' ');
155
+ const labelQuery = buildLabelQuery(labels);
206
156
  // When languages includes 'any', omit the language filter entirely
207
157
  const isAnyLanguage = languages.some((l) => l.toLowerCase() === 'any');
208
158
  const langQuery = isAnyLanguage ? '' : languages.map((l) => `language:${l}`).join(' ');
@@ -241,8 +191,6 @@ export class IssueDiscovery {
241
191
  });
242
192
  };
243
193
  // Phase 0: Search repos where user has merged PRs + open-PR repos (highest merge probability)
244
- // Uses broader query — established contributors don't need "good first issue" labels
245
- // Merged-PR repos come first, then open-PR repos fill remaining slots (capped at 10 total)
246
194
  const phase0Repos = [...mergedPRRepos, ...openPRRepos.filter((r) => !mergedPRRepoSet.has(r))].slice(0, 10);
247
195
  const phase0RepoSet = new Set(phase0Repos);
248
196
  if (phase0Repos.length > 0) {
@@ -254,7 +202,7 @@ export class IssueDiscovery {
254
202
  if (mergedPhase0Repos.length > 0) {
255
203
  const remainingNeeded = maxResults - allCandidates.length;
256
204
  if (remainingNeeded > 0) {
257
- const { candidates: mergedCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(mergedPhase0Repos, establishedQuery, remainingNeeded, 'merged_pr', filterIssues);
205
+ const { candidates: mergedCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, mergedPhase0Repos, establishedQuery, remainingNeeded, 'merged_pr', filterIssues);
258
206
  allCandidates.push(...mergedCandidates);
259
207
  if (allBatchesFailed) {
260
208
  phase0Error = 'All merged-PR repo batches failed';
@@ -270,7 +218,7 @@ export class IssueDiscovery {
270
218
  if (openPhase0Repos.length > 0 && allCandidates.length < maxResults) {
271
219
  const remainingNeeded = maxResults - allCandidates.length;
272
220
  if (remainingNeeded > 0) {
273
- const { candidates: openCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(openPhase0Repos, establishedQuery, remainingNeeded, 'starred', filterIssues);
221
+ const { candidates: openCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, openPhase0Repos, establishedQuery, remainingNeeded, 'starred', filterIssues);
274
222
  allCandidates.push(...openCandidates);
275
223
  if (allBatchesFailed) {
276
224
  const msg = 'All open-PR repo batches failed';
@@ -296,7 +244,7 @@ export class IssueDiscovery {
296
244
  const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(' OR ');
297
245
  const orgQuery = `${baseQuery} (${orgRepoFilter})`;
298
246
  try {
299
- const data = await this.cachedSearch({
247
+ const data = await cachedSearchIssues(this.octokit, {
300
248
  q: orgQuery,
301
249
  sort: 'created',
302
250
  order: 'desc',
@@ -335,7 +283,7 @@ export class IssueDiscovery {
335
283
  info(MODULE, `Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
336
284
  const remainingNeeded = maxResults - allCandidates.length;
337
285
  if (remainingNeeded > 0) {
338
- const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(reposToSearch.slice(0, 10), baseQuery, remainingNeeded, 'starred', filterIssues);
286
+ const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, reposToSearch.slice(0, 10), baseQuery, remainingNeeded, 'starred', filterIssues);
339
287
  allCandidates.push(...starredCandidates);
340
288
  if (allBatchesFailed) {
341
289
  phase1Error = 'All starred repo batches failed';
@@ -348,42 +296,81 @@ export class IssueDiscovery {
348
296
  }
349
297
  }
350
298
  // Phase 2: General search (if still need more)
299
+ // When multiple scope tiers are active, fire one query per tier and interleave
300
+ // results to prevent high-volume tiers (e.g., "enhancement") from drowning out
301
+ // beginner results.
351
302
  let phase2Error = null;
352
303
  if (allCandidates.length < maxResults) {
353
304
  info(MODULE, 'Phase 2: General issue search...');
354
305
  const remainingNeeded = maxResults - allCandidates.length;
355
- try {
356
- const data = await this.cachedSearch({
357
- q: baseQuery,
358
- sort: 'created',
359
- order: 'desc',
360
- per_page: remainingNeeded * 3, // Fetch extra since some will be filtered
361
- });
362
- info(MODULE, `Found ${data.total_count} issues in general search, processing top ${data.items.length}...`);
363
- const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
364
- const { candidates: starFiltered, allVetFailed, rateLimitHit: vetRateLimitHit, } = await this.filterVetAndScore(data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], remainingNeeded, minStars, 'Phase 2');
365
- allCandidates.push(...starFiltered);
366
- if (allVetFailed) {
367
- phase2Error = (phase2Error ? phase2Error + '; ' : '') + 'all vetting failed';
306
+ const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
307
+ // Build per-tier label groups. Multi-tier when 2+ scopes; single-tier otherwise.
308
+ const tierLabelGroups = [];
309
+ if (scopes && scopes.length > 1) {
310
+ for (const scope of scopes) {
311
+ const scopeLabels = SCOPE_LABELS[scope] ?? [];
312
+ if (scopeLabels.length === 0) {
313
+ warn(MODULE, `Scope "${scope}" has no labels, skipping tier`);
314
+ continue;
315
+ }
316
+ tierLabelGroups.push({ tier: scope, tierLabels: scopeLabels });
368
317
  }
369
- if (vetRateLimitHit) {
370
- rateLimitHitDuringSearch = true;
318
+ // Custom labels not in any tier get their own pseudo-tier
319
+ const allScopeLabels = new Set(scopes.flatMap((s) => SCOPE_LABELS[s] ?? []));
320
+ const customOnly = config.labels.filter((l) => !allScopeLabels.has(l));
321
+ if (customOnly.length > 0) {
322
+ tierLabelGroups.push({ tier: 'custom', tierLabels: customOnly });
371
323
  }
372
- info(MODULE, `Found ${starFiltered.length} candidates from general search`);
373
324
  }
374
- catch (error) {
375
- const errMsg = errorMessage(error);
376
- phase2Error = errMsg;
377
- if (isRateLimitError(error)) {
378
- rateLimitHitDuringSearch = true;
325
+ else {
326
+ tierLabelGroups.push({ tier: 'general', tierLabels: labels });
327
+ }
328
+ const budgetPerTier = Math.ceil(remainingNeeded / tierLabelGroups.length);
329
+ const tierResults = [];
330
+ for (const { tier, tierLabels } of tierLabelGroups) {
331
+ const tierQuery = `is:issue is:open ${buildLabelQuery(tierLabels)} ${langQuery} no:assignee`
332
+ .replace(/ +/g, ' ')
333
+ .trim();
334
+ try {
335
+ const data = await cachedSearchIssues(this.octokit, {
336
+ q: tierQuery,
337
+ sort: 'created',
338
+ order: 'desc',
339
+ per_page: budgetPerTier * 3,
340
+ });
341
+ info(MODULE, `Phase 2 [${tier}]: ${data.total_count} total, processing top ${data.items.length}...`);
342
+ const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(this.vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
343
+ tierResults.push(tierCandidates);
344
+ // Update seenRepos so later tiers don't return duplicate repos
345
+ for (const c of tierCandidates)
346
+ seenRepos.add(c.issue.repo);
347
+ if (allVetFailed) {
348
+ phase2Error = (phase2Error ? phase2Error + '; ' : '') + `${tier}: all vetting failed`;
349
+ }
350
+ if (vetRateLimitHit) {
351
+ rateLimitHitDuringSearch = true;
352
+ }
353
+ info(MODULE, `Found ${tierCandidates.length} candidates from ${tier} tier`);
354
+ }
355
+ catch (error) {
356
+ if (getHttpStatusCode(error) === 401)
357
+ throw error;
358
+ const errMsg = errorMessage(error);
359
+ phase2Error = (phase2Error ? phase2Error + '; ' : '') + `${tier}: ${errMsg}`;
360
+ if (isRateLimitError(error)) {
361
+ rateLimitHitDuringSearch = true;
362
+ }
363
+ warn(MODULE, `Error in ${tier} tier search: ${errMsg}`);
364
+ tierResults.push([]);
379
365
  }
380
- warn(MODULE, `Error in general issue search: ${errMsg}`);
381
366
  }
367
+ const interleaved = interleaveArrays(tierResults);
368
+ if (interleaved.length === 0 && phase2Error) {
369
+ warn(MODULE, `All ${tierLabelGroups.length} scope tiers failed in Phase 2: ${phase2Error}`);
370
+ }
371
+ allCandidates.push(...interleaved.slice(0, remainingNeeded));
382
372
  }
383
373
  // Phase 3: Actively maintained repos (#349)
384
- // Searches the "long tail" of well-maintained repos (50+ stars, recently pushed,
385
- // not archived) that Phase 2 may miss because they aren't trending or pre-filtered.
386
- // Uses label-free query to cast a wider net focused on repo health.
387
374
  let phase3Error = null;
388
375
  if (allCandidates.length < maxResults) {
389
376
  info(MODULE, 'Phase 3: Searching actively maintained repos...');
@@ -391,16 +378,13 @@ export class IssueDiscovery {
391
378
  const thirtyDaysAgo = new Date();
392
379
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
393
380
  const pushedSince = thirtyDaysAgo.toISOString().split('T')[0];
394
- // When user has category preferences, add a single topic filter to focus on relevant repos.
395
- // GitHub Search API AND-joins multiple topic: qualifiers, which is overly restrictive,
396
- // so we pick just the first topic to nudge results without eliminating valid matches.
397
381
  const categoryTopics = getTopicsForCategories(config.projectCategories ?? []);
398
382
  const topicQuery = categoryTopics.length > 0 ? `topic:${categoryTopics[0]}` : '';
399
383
  const phase3Query = `is:issue is:open no:assignee ${langQuery} ${topicQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
400
384
  .replace(/ +/g, ' ')
401
385
  .trim();
402
386
  try {
403
- const data = await this.cachedSearch({
387
+ const data = await cachedSearchIssues(this.octokit, {
404
388
  q: phase3Query,
405
389
  sort: 'updated',
406
390
  order: 'desc',
@@ -408,7 +392,7 @@ export class IssueDiscovery {
408
392
  });
409
393
  info(MODULE, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
410
394
  const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
411
- const { candidates: starFiltered, allVetFailed, rateLimitHit: vetRateLimitHit, } = await this.filterVetAndScore(data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], remainingNeeded, minStars, 'Phase 3');
395
+ const { candidates: starFiltered, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(this.vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], remainingNeeded, minStars, 'Phase 3');
412
396
  allCandidates.push(...starFiltered);
413
397
  if (allVetFailed) {
414
398
  phase3Error = 'all vetting failed';
@@ -436,8 +420,6 @@ export class IssueDiscovery {
436
420
  phase3Error ? `Phase 3 (maintained repos): ${phase3Error}` : null,
437
421
  ].filter(Boolean);
438
422
  const details = phaseErrors.length > 0 ? ` ${phaseErrors.join('. ')}.` : '';
439
- // When rate limits caused zero results, return empty array with warning
440
- // instead of throwing, so callers can handle it gracefully
441
423
  if (rateLimitHitDuringSearch) {
442
424
  this.rateLimitWarning =
443
425
  `Search returned no results due to GitHub API rate limits.${details} ` +
@@ -448,8 +430,6 @@ export class IssueDiscovery {
448
430
  'Try adjusting your search criteria (languages, labels) or check your network connection.');
449
431
  }
450
432
  // Surface rate limit warning even with partial results (#100)
451
- // This overwrites the pre-flight "quota low" warning (speculative) with a more
452
- // informative "results incomplete" warning (factual) when rate limits actually hit.
453
433
  if (rateLimitHitDuringSearch) {
454
434
  this.rateLimitWarning =
455
435
  `Search results may be incomplete: GitHub API rate limits were hit during search. ` +
@@ -458,109 +438,26 @@ export class IssueDiscovery {
458
438
  }
459
439
  // Sort by priority first, then by recommendation, then by viability score
460
440
  allCandidates.sort((a, b) => {
461
- // Priority order: merged_pr > preferred_org > starred > normal
462
441
  const priorityOrder = { merged_pr: 0, preferred_org: 1, starred: 2, normal: 3 };
463
442
  const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
464
443
  if (priorityDiff !== 0)
465
444
  return priorityDiff;
466
- // Then by recommendation
467
445
  const recommendationOrder = { approve: 0, needs_review: 1, skip: 2 };
468
446
  const recDiff = recommendationOrder[a.recommendation] - recommendationOrder[b.recommendation];
469
447
  if (recDiff !== 0)
470
448
  return recDiff;
471
- // Then by viability score (highest first)
472
449
  return b.viabilityScore - a.viabilityScore;
473
450
  });
474
451
  // Apply per-repo cap: max 2 issues from any single repo (#105)
475
452
  const capped = applyPerRepoCap(allCandidates, 2);
476
453
  return capped.slice(0, maxResults);
477
454
  }
478
- /**
479
- * Search for issues within specific repos using batched queries.
480
- *
481
- * To avoid GitHub's secondary rate limit (30 requests/minute), we batch
482
- * multiple repos into a single search query using OR syntax:
483
- * repo:owner1/repo1 OR repo:owner2/repo2 OR repo:owner3/repo3
484
- *
485
- * This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE).
486
- */
487
- async searchInRepos(repos, baseQuery, maxResults, priority, filterFn) {
488
- const candidates = [];
489
- // Batch repos to reduce API calls.
490
- // GitHub search query has a max length (~256 chars for query part).
491
- // Each "repo:owner/repo" is ~20-40 chars, plus " OR " (4 chars).
492
- // Using 5 repos per batch stays well under the limit.
493
- const BATCH_SIZE = 5;
494
- const batches = this.batchRepos(repos, BATCH_SIZE);
495
- let failedBatches = 0;
496
- let rateLimitFailures = 0;
497
- for (const batch of batches) {
498
- if (candidates.length >= maxResults)
499
- break;
500
- try {
501
- // Build repo filter: (repo:a OR repo:b OR repo:c)
502
- const repoFilter = batch.map((r) => `repo:${r}`).join(' OR ');
503
- const batchQuery = `${baseQuery} (${repoFilter})`;
504
- const data = await this.cachedSearch({
505
- q: batchQuery,
506
- sort: 'created',
507
- order: 'desc',
508
- per_page: Math.min(30, (maxResults - candidates.length) * 3),
509
- });
510
- if (data.items.length > 0) {
511
- const filtered = filterFn(data.items);
512
- const remainingNeeded = maxResults - candidates.length;
513
- const { candidates: vetted } = await this.vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, priority);
514
- candidates.push(...vetted);
515
- }
516
- }
517
- catch (error) {
518
- failedBatches++;
519
- if (isRateLimitError(error)) {
520
- rateLimitFailures++;
521
- }
522
- const batchRepos = batch.join(', ');
523
- warn(MODULE, `Error searching issues in batch [${batchRepos}]:`, errorMessage(error));
524
- }
525
- }
526
- const allBatchesFailed = failedBatches === batches.length && batches.length > 0;
527
- const rateLimitHit = rateLimitFailures > 0;
528
- if (allBatchesFailed) {
529
- warn(MODULE, `All ${batches.length} batch(es) failed for ${priority} phase. ` +
530
- `This may indicate a systemic issue (rate limit, auth, network).`);
531
- }
532
- return { candidates, allBatchesFailed, rateLimitHit };
533
- }
534
- /**
535
- * Split repos into batches of the specified size.
536
- */
537
- batchRepos(repos, batchSize) {
538
- const batches = [];
539
- for (let i = 0; i < repos.length; i += batchSize) {
540
- batches.push(repos.slice(i, i + batchSize));
541
- }
542
- return batches;
543
- }
544
455
  /**
545
456
  * Vet a specific issue (delegates to IssueVetter).
546
457
  */
547
458
  async vetIssue(issueUrl) {
548
459
  return this.vetter.vetIssue(issueUrl);
549
460
  }
550
- /**
551
- * Analyze issue requirements for clarity (delegates to IssueVetter).
552
- * Kept on class for backward compatibility.
553
- */
554
- analyzeRequirements(body) {
555
- return this.vetter.analyzeRequirements(body);
556
- }
557
- /**
558
- * Calculate viability score for an issue (delegates to issue-scoring module).
559
- * Kept on class for backward compatibility with tests that call instance.calculateViabilityScore().
560
- */
561
- calculateViabilityScore(params) {
562
- return calcViabilityScore(params);
563
- }
564
461
  /**
565
462
  * Save search results to ~/.oss-autopilot/found-issues.md
566
463
  * Results are sorted by viability score (highest first)
@@ -570,8 +467,6 @@ export class IssueDiscovery {
570
467
  const sorted = [...candidates].sort((a, b) => b.viabilityScore - a.viabilityScore);
571
468
  const outputDir = getDataDir();
572
469
  const outputFile = path.join(outputDir, 'found-issues.md');
573
- // Directory is created by getDataDir() if needed
574
- // Generate markdown content
575
470
  const timestamp = new Date().toISOString();
576
471
  let content = `# Found Issues\n\n`;
577
472
  content += `> Generated at: ${timestamp}\n\n`;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Issue Eligibility — checks whether an individual issue is claimable:
3
+ * existing PR detection, claim-phrase scanning, user merge history,
4
+ * and requirement clarity analysis.
5
+ *
6
+ * Extracted from issue-vetting.ts (#621) to isolate eligibility logic.
7
+ */
8
+ import { Octokit } from '@octokit/rest';
9
+ /** Result of a vetting check that may be inconclusive due to API errors. */
10
+ export interface CheckResult {
11
+ passed: boolean;
12
+ inconclusive?: boolean;
13
+ reason?: string;
14
+ }
15
+ /**
16
+ * Check whether an open PR already exists for the given issue.
17
+ * Searches both the PR search index and the issue timeline for linked PRs.
18
+ */
19
+ export declare function checkNoExistingPR(octokit: Octokit, owner: string, repo: string, issueNumber: number): Promise<CheckResult>;
20
+ /**
21
+ * Check how many merged PRs the authenticated user has in a repo.
22
+ * Uses GitHub Search API. Returns 0 on error (non-fatal).
23
+ */
24
+ export declare function checkUserMergedPRsInRepo(octokit: Octokit, owner: string, repo: string): Promise<number>;
25
+ /**
26
+ * Check whether an issue has been claimed by another contributor
27
+ * by scanning recent comments for claim phrases.
28
+ */
29
+ export declare function checkNotClaimed(octokit: Octokit, owner: string, repo: string, issueNumber: number, commentCount: number): Promise<CheckResult>;
30
+ /**
31
+ * Analyze whether an issue body has clear, actionable requirements.
32
+ * Returns true when at least two "clarity indicators" are present:
33
+ * numbered/bulleted steps, code blocks, expected-behavior keywords, length > 200.
34
+ */
35
+ export declare function analyzeRequirements(body: string): boolean;