@oss-autopilot/core 0.41.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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/cli.bundle.cjs +17657 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +325 -0
- package/dist/commands/check-integration.d.ts +10 -0
- package/dist/commands/check-integration.js +192 -0
- package/dist/commands/comments.d.ts +24 -0
- package/dist/commands/comments.js +311 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/daily.d.ts +29 -0
- package/dist/commands/daily.js +433 -0
- package/dist/commands/dashboard-data.d.ts +45 -0
- package/dist/commands/dashboard-data.js +132 -0
- package/dist/commands/dashboard-templates.d.ts +23 -0
- package/dist/commands/dashboard-templates.js +1627 -0
- package/dist/commands/dashboard.d.ts +18 -0
- package/dist/commands/dashboard.js +134 -0
- package/dist/commands/dismiss.d.ts +13 -0
- package/dist/commands/dismiss.js +49 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/local-repos.d.ts +14 -0
- package/dist/commands/local-repos.js +155 -0
- package/dist/commands/parse-list.d.ts +13 -0
- package/dist/commands/parse-list.js +139 -0
- package/dist/commands/read.d.ts +12 -0
- package/dist/commands/read.js +33 -0
- package/dist/commands/search.d.ts +10 -0
- package/dist/commands/search.js +74 -0
- package/dist/commands/setup.d.ts +15 -0
- package/dist/commands/setup.js +276 -0
- package/dist/commands/shelve.d.ts +13 -0
- package/dist/commands/shelve.js +49 -0
- package/dist/commands/snooze.d.ts +18 -0
- package/dist/commands/snooze.js +83 -0
- package/dist/commands/startup.d.ts +33 -0
- package/dist/commands/startup.js +197 -0
- package/dist/commands/status.d.ts +10 -0
- package/dist/commands/status.js +43 -0
- package/dist/commands/track.d.ts +16 -0
- package/dist/commands/track.js +59 -0
- package/dist/commands/validation.d.ts +43 -0
- package/dist/commands/validation.js +112 -0
- package/dist/commands/vet.d.ts +10 -0
- package/dist/commands/vet.js +36 -0
- package/dist/core/checklist-analysis.d.ts +17 -0
- package/dist/core/checklist-analysis.js +39 -0
- package/dist/core/ci-analysis.d.ts +78 -0
- package/dist/core/ci-analysis.js +163 -0
- package/dist/core/comment-utils.d.ts +15 -0
- package/dist/core/comment-utils.js +52 -0
- package/dist/core/concurrency.d.ts +5 -0
- package/dist/core/concurrency.js +15 -0
- package/dist/core/daily-logic.d.ts +77 -0
- package/dist/core/daily-logic.js +512 -0
- package/dist/core/display-utils.d.ts +10 -0
- package/dist/core/display-utils.js +100 -0
- package/dist/core/errors.d.ts +24 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/github-stats.d.ts +73 -0
- package/dist/core/github-stats.js +272 -0
- package/dist/core/github.d.ts +19 -0
- package/dist/core/github.js +60 -0
- package/dist/core/http-cache.d.ts +97 -0
- package/dist/core/http-cache.js +269 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +15 -0
- package/dist/core/issue-conversation.d.ts +29 -0
- package/dist/core/issue-conversation.js +231 -0
- package/dist/core/issue-discovery.d.ts +85 -0
- package/dist/core/issue-discovery.js +589 -0
- package/dist/core/issue-filtering.d.ts +51 -0
- package/dist/core/issue-filtering.js +103 -0
- package/dist/core/issue-scoring.d.ts +40 -0
- package/dist/core/issue-scoring.js +92 -0
- package/dist/core/issue-vetting.d.ts +49 -0
- package/dist/core/issue-vetting.js +536 -0
- package/dist/core/logger.d.ts +21 -0
- package/dist/core/logger.js +49 -0
- package/dist/core/maintainer-analysis.d.ts +10 -0
- package/dist/core/maintainer-analysis.js +59 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +20 -0
- package/dist/core/pr-monitor.d.ts +109 -0
- package/dist/core/pr-monitor.js +594 -0
- package/dist/core/review-analysis.d.ts +72 -0
- package/dist/core/review-analysis.js +163 -0
- package/dist/core/state.d.ts +371 -0
- package/dist/core/state.js +1089 -0
- package/dist/core/types.d.ts +507 -0
- package/dist/core/types.js +34 -0
- package/dist/core/utils.d.ts +249 -0
- package/dist/core/utils.js +422 -0
- package/dist/formatters/json.d.ts +269 -0
- package/dist/formatters/json.js +88 -0
- package/package.json +67 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Discovery — orchestrates multi-phase issue search across GitHub.
|
|
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)
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { getOctokit, checkRateLimit } from './github.js';
|
|
12
|
+
import { getStateManager } from './state.js';
|
|
13
|
+
import { daysBetween, getDataDir } from './utils.js';
|
|
14
|
+
import { DEFAULT_CONFIG } from './types.js';
|
|
15
|
+
import { ValidationError } from './errors.js';
|
|
16
|
+
import { warn } from './logger.js';
|
|
17
|
+
import { isDocOnlyIssue, detectLabelFarmingRepos, applyPerRepoCap } from './issue-filtering.js';
|
|
18
|
+
import { IssueVetter } from './issue-vetting.js';
|
|
19
|
+
import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.js';
|
|
20
|
+
// Re-export everything from sub-modules for backward compatibility.
|
|
21
|
+
// Existing consumers (tests, CLI commands) import from './issue-discovery.js'.
|
|
22
|
+
export { isDocOnlyIssue, applyPerRepoCap, isLabelFarming, hasTemplatedTitle, detectLabelFarmingRepos, DOC_ONLY_LABELS, BEGINNER_LABELS, } from './issue-filtering.js';
|
|
23
|
+
export { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
|
|
24
|
+
const MODULE = 'issue-discovery';
|
|
25
|
+
export class IssueDiscovery {
|
|
26
|
+
octokit;
|
|
27
|
+
stateManager;
|
|
28
|
+
githubToken;
|
|
29
|
+
vetter;
|
|
30
|
+
/** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
|
|
31
|
+
rateLimitWarning = null;
|
|
32
|
+
constructor(githubToken) {
|
|
33
|
+
this.githubToken = githubToken;
|
|
34
|
+
this.octokit = getOctokit(githubToken);
|
|
35
|
+
this.stateManager = getStateManager();
|
|
36
|
+
this.vetter = new IssueVetter(this.octokit, this.stateManager);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fetch the authenticated user's starred repositories from GitHub.
|
|
40
|
+
* Updates the state manager with the list and timestamp.
|
|
41
|
+
*/
|
|
42
|
+
async fetchStarredRepos() {
|
|
43
|
+
console.log('Fetching starred repositories...');
|
|
44
|
+
const starredRepos = [];
|
|
45
|
+
try {
|
|
46
|
+
// Paginate through all starred repos (up to 500 to avoid excessive API calls)
|
|
47
|
+
const iterator = this.octokit.paginate.iterator(this.octokit.activity.listReposStarredByAuthenticatedUser, {
|
|
48
|
+
per_page: 100,
|
|
49
|
+
});
|
|
50
|
+
let pageCount = 0;
|
|
51
|
+
for await (const { data: repos } of iterator) {
|
|
52
|
+
for (const repo of repos) {
|
|
53
|
+
// Handle both Repository and StarredRepository response types
|
|
54
|
+
// Repository has full_name directly, StarredRepository has { repo: Repository }
|
|
55
|
+
let fullName;
|
|
56
|
+
if ('full_name' in repo && typeof repo.full_name === 'string') {
|
|
57
|
+
// Repository type - full_name is directly on the object
|
|
58
|
+
fullName = repo.full_name;
|
|
59
|
+
}
|
|
60
|
+
else if ('repo' in repo && repo.repo && typeof repo.repo === 'object' && 'full_name' in repo.repo) {
|
|
61
|
+
// StarredRepository type - full_name is nested in repo property
|
|
62
|
+
fullName = repo.repo.full_name;
|
|
63
|
+
}
|
|
64
|
+
if (fullName) {
|
|
65
|
+
starredRepos.push(fullName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
pageCount++;
|
|
69
|
+
// Limit to 5 pages (500 repos) to avoid excessive API usage
|
|
70
|
+
if (pageCount >= 5) {
|
|
71
|
+
console.log('Reached pagination limit for starred repos (500)');
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
console.log(`Fetched ${starredRepos.length} starred repositories`);
|
|
76
|
+
this.stateManager.setStarredRepos(starredRepos);
|
|
77
|
+
return starredRepos;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const cachedRepos = this.stateManager.getStarredRepos();
|
|
81
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
82
|
+
warn(MODULE, 'Error fetching starred repos:', errorMessage);
|
|
83
|
+
if (cachedRepos.length === 0) {
|
|
84
|
+
warn(MODULE, `Failed to fetch starred repositories from GitHub API. ` +
|
|
85
|
+
`No cached repos available. Error: ${errorMessage}\n` +
|
|
86
|
+
`Tip: Ensure your GITHUB_TOKEN has the 'read:user' scope and try again.`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
warn(MODULE, `Failed to fetch starred repositories from GitHub API. ` +
|
|
90
|
+
`Using ${cachedRepos.length} cached repos instead. Error: ${errorMessage}`);
|
|
91
|
+
}
|
|
92
|
+
return cachedRepos;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get starred repos, fetching from GitHub if cache is stale
|
|
97
|
+
*/
|
|
98
|
+
async getStarredReposWithRefresh() {
|
|
99
|
+
if (this.stateManager.isStarredReposStale()) {
|
|
100
|
+
return this.fetchStarredRepos();
|
|
101
|
+
}
|
|
102
|
+
return this.stateManager.getStarredRepos();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Search for issues matching our criteria.
|
|
106
|
+
* Searches in priority order: merged-PR repos first (no label filter), then starred repos,
|
|
107
|
+
* then general search, then actively maintained repos (#349).
|
|
108
|
+
* Filters out issues from low-scoring and excluded repos.
|
|
109
|
+
*/
|
|
110
|
+
async searchIssues(options = {}) {
|
|
111
|
+
const config = this.stateManager.getState().config;
|
|
112
|
+
const languages = options.languages || config.languages;
|
|
113
|
+
const labels = options.labels || config.labels;
|
|
114
|
+
const maxResults = options.maxResults || 10;
|
|
115
|
+
const allCandidates = [];
|
|
116
|
+
let phase0Error = null;
|
|
117
|
+
let phase1Error = null;
|
|
118
|
+
let rateLimitHitDuringSearch = false;
|
|
119
|
+
// Pre-flight rate limit check (#100)
|
|
120
|
+
this.rateLimitWarning = null;
|
|
121
|
+
try {
|
|
122
|
+
const rateLimit = await checkRateLimit(this.githubToken);
|
|
123
|
+
if (rateLimit.remaining < 5) {
|
|
124
|
+
const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString('en-US', { hour12: false });
|
|
125
|
+
this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
|
|
126
|
+
warn(MODULE, this.rateLimitWarning);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// Fail fast on auth errors — no point searching with a bad token
|
|
131
|
+
if (error?.status === 401) {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
// Non-fatal: proceed with search for transient/network errors
|
|
135
|
+
warn(MODULE, 'Could not check rate limit:', error instanceof Error ? error.message : error);
|
|
136
|
+
}
|
|
137
|
+
// Get merged-PR repos (highest merge probability)
|
|
138
|
+
const mergedPRRepos = this.stateManager.getReposWithMergedPRs();
|
|
139
|
+
const mergedPRRepoSet = new Set(mergedPRRepos);
|
|
140
|
+
// Get open-PR repos (repos with score data but no merges yet)
|
|
141
|
+
const openPRRepos = this.stateManager.getReposWithOpenPRs();
|
|
142
|
+
// Get starred repos (with refresh if stale)
|
|
143
|
+
const starredRepos = await this.getStarredReposWithRefresh();
|
|
144
|
+
const starredRepoSet = new Set(starredRepos);
|
|
145
|
+
// Get low-scoring repos from state
|
|
146
|
+
const lowScoringRepos = new Set(this.stateManager.getLowScoringRepos(3)); // Score <= 3 is low
|
|
147
|
+
// Common filters
|
|
148
|
+
const trackedUrls = new Set(this.stateManager.getState().activeIssues.map((i) => i.url));
|
|
149
|
+
const excludedRepos = new Set(config.excludeRepos);
|
|
150
|
+
const maxAgeDays = config.maxIssueAgeDays || 90;
|
|
151
|
+
const now = new Date();
|
|
152
|
+
// Build query parts
|
|
153
|
+
const labelQuery = labels.map((l) => `label:"${l}"`).join(' ');
|
|
154
|
+
// When languages includes 'any', omit the language filter entirely
|
|
155
|
+
const isAnyLanguage = languages.some((l) => l.toLowerCase() === 'any');
|
|
156
|
+
const langQuery = isAnyLanguage ? '' : languages.map((l) => `language:${l}`).join(' ');
|
|
157
|
+
// Phase 0 uses a broader query — established contributors don't need beginner labels
|
|
158
|
+
const establishedQuery = `is:issue is:open ${langQuery} no:assignee`.replace(/ +/g, ' ').trim();
|
|
159
|
+
// Phases 1+ use label-filtered query for discovery in unfamiliar repos
|
|
160
|
+
const baseQuery = `is:issue is:open ${labelQuery} ${langQuery} no:assignee`.replace(/ +/g, ' ').trim();
|
|
161
|
+
// Helper to filter issues
|
|
162
|
+
const includeDocIssues = config.includeDocIssues ?? true;
|
|
163
|
+
const aiBlocklisted = new Set(config.aiPolicyBlocklist ?? DEFAULT_CONFIG.aiPolicyBlocklist ?? []);
|
|
164
|
+
if (aiBlocklisted.size > 0) {
|
|
165
|
+
console.log(`[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(', ')}`);
|
|
166
|
+
}
|
|
167
|
+
const filterIssues = (items) => {
|
|
168
|
+
return items.filter((item) => {
|
|
169
|
+
if (trackedUrls.has(item.html_url))
|
|
170
|
+
return false;
|
|
171
|
+
const repoFullName = item.repository_url.split('/').slice(-2).join('/');
|
|
172
|
+
if (excludedRepos.has(repoFullName))
|
|
173
|
+
return false;
|
|
174
|
+
// Filter repos with known anti-AI contribution policies (#108)
|
|
175
|
+
if (aiBlocklisted.has(repoFullName))
|
|
176
|
+
return false;
|
|
177
|
+
// Filter OUT low-scoring repos
|
|
178
|
+
if (lowScoringRepos.has(repoFullName))
|
|
179
|
+
return false;
|
|
180
|
+
// Filter by issue age based on updated_at
|
|
181
|
+
const updatedAt = new Date(item.updated_at);
|
|
182
|
+
const ageDays = daysBetween(updatedAt, now);
|
|
183
|
+
if (ageDays > maxAgeDays)
|
|
184
|
+
return false;
|
|
185
|
+
// Filter out doc-only issues unless opted in (#105)
|
|
186
|
+
if (!includeDocIssues && isDocOnlyIssue(item))
|
|
187
|
+
return false;
|
|
188
|
+
return true;
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
// Phase 0: Search repos where user has merged PRs + open-PR repos (highest merge probability)
|
|
192
|
+
// Uses broader query — established contributors don't need "good first issue" labels
|
|
193
|
+
// Merged-PR repos come first, then open-PR repos fill remaining slots (capped at 10 total)
|
|
194
|
+
const phase0Repos = [...mergedPRRepos, ...openPRRepos.filter((r) => !mergedPRRepoSet.has(r))].slice(0, 10);
|
|
195
|
+
const phase0RepoSet = new Set(phase0Repos);
|
|
196
|
+
if (phase0Repos.length > 0) {
|
|
197
|
+
const mergedInPhase0 = Math.min(mergedPRRepos.length, phase0Repos.length);
|
|
198
|
+
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)...`);
|
|
200
|
+
// Phase 0a: merged-PR repos (priority: merged_pr)
|
|
201
|
+
const mergedPhase0Repos = phase0Repos.slice(0, mergedInPhase0);
|
|
202
|
+
if (mergedPhase0Repos.length > 0) {
|
|
203
|
+
const remainingNeeded = maxResults - allCandidates.length;
|
|
204
|
+
if (remainingNeeded > 0) {
|
|
205
|
+
const { candidates: mergedCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(mergedPhase0Repos, establishedQuery, remainingNeeded, 'merged_pr', filterIssues);
|
|
206
|
+
allCandidates.push(...mergedCandidates);
|
|
207
|
+
if (allBatchesFailed) {
|
|
208
|
+
phase0Error = 'All merged-PR repo batches failed';
|
|
209
|
+
}
|
|
210
|
+
if (rateLimitHit) {
|
|
211
|
+
rateLimitHitDuringSearch = true;
|
|
212
|
+
}
|
|
213
|
+
console.log(`Found ${mergedCandidates.length} candidates from merged-PR repos`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Phase 0b: open-PR repos (priority: starred — intermediate tier)
|
|
217
|
+
const openPhase0Repos = phase0Repos.slice(mergedInPhase0);
|
|
218
|
+
if (openPhase0Repos.length > 0 && allCandidates.length < maxResults) {
|
|
219
|
+
const remainingNeeded = maxResults - allCandidates.length;
|
|
220
|
+
if (remainingNeeded > 0) {
|
|
221
|
+
const { candidates: openCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(openPhase0Repos, establishedQuery, remainingNeeded, 'starred', filterIssues);
|
|
222
|
+
allCandidates.push(...openCandidates);
|
|
223
|
+
if (allBatchesFailed) {
|
|
224
|
+
const msg = 'All open-PR repo batches failed';
|
|
225
|
+
phase0Error = phase0Error ? `${phase0Error}; ${msg}` : msg;
|
|
226
|
+
}
|
|
227
|
+
if (rateLimitHit) {
|
|
228
|
+
rateLimitHitDuringSearch = true;
|
|
229
|
+
}
|
|
230
|
+
console.log(`Found ${openCandidates.length} candidates from open-PR repos`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Phase 1: Search starred repos (filter out already-searched Phase 0 repos)
|
|
235
|
+
if (allCandidates.length < maxResults && starredRepos.length > 0) {
|
|
236
|
+
const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
|
|
237
|
+
if (reposToSearch.length > 0) {
|
|
238
|
+
console.log(`Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
|
|
239
|
+
const remainingNeeded = maxResults - allCandidates.length;
|
|
240
|
+
if (remainingNeeded > 0) {
|
|
241
|
+
const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await this.searchInRepos(reposToSearch.slice(0, 10), baseQuery, remainingNeeded, 'starred', filterIssues);
|
|
242
|
+
allCandidates.push(...starredCandidates);
|
|
243
|
+
if (allBatchesFailed) {
|
|
244
|
+
phase1Error = 'All starred repo batches failed';
|
|
245
|
+
}
|
|
246
|
+
if (rateLimitHit) {
|
|
247
|
+
rateLimitHitDuringSearch = true;
|
|
248
|
+
}
|
|
249
|
+
console.log(`Found ${starredCandidates.length} candidates from starred repos`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Phase 2: General search (if still need more)
|
|
254
|
+
let phase2Error = null;
|
|
255
|
+
if (allCandidates.length < maxResults) {
|
|
256
|
+
console.log('Phase 2: General issue search...');
|
|
257
|
+
const remainingNeeded = maxResults - allCandidates.length;
|
|
258
|
+
try {
|
|
259
|
+
const { data } = await this.octokit.search.issuesAndPullRequests({
|
|
260
|
+
q: baseQuery,
|
|
261
|
+
sort: 'created',
|
|
262
|
+
order: 'desc',
|
|
263
|
+
per_page: remainingNeeded * 3, // Fetch extra since some will be filtered
|
|
264
|
+
});
|
|
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
|
|
273
|
+
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
|
+
}
|
|
299
|
+
allCandidates.push(...starFiltered);
|
|
300
|
+
if (allVetFailed) {
|
|
301
|
+
phase2Error = (phase2Error ? phase2Error + '; ' : '') + 'all vetting failed';
|
|
302
|
+
}
|
|
303
|
+
if (vetRateLimitHit) {
|
|
304
|
+
rateLimitHitDuringSearch = true;
|
|
305
|
+
}
|
|
306
|
+
console.log(`Found ${starFiltered.length} candidates from general search`);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
310
|
+
phase2Error = errorMessage;
|
|
311
|
+
if (IssueVetter.isRateLimitError(error)) {
|
|
312
|
+
rateLimitHitDuringSearch = true;
|
|
313
|
+
}
|
|
314
|
+
warn(MODULE, `Error in general issue search: ${errorMessage}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Phase 3: Actively maintained repos (#349)
|
|
318
|
+
// Searches the "long tail" of well-maintained repos (50+ stars, recently pushed,
|
|
319
|
+
// not archived) that Phase 2 may miss because they aren't trending or pre-filtered.
|
|
320
|
+
// Uses label-free query to cast a wider net focused on repo health.
|
|
321
|
+
let phase3Error = null;
|
|
322
|
+
if (allCandidates.length < maxResults) {
|
|
323
|
+
console.log('Phase 3: Searching actively maintained repos...');
|
|
324
|
+
const remainingNeeded = maxResults - allCandidates.length;
|
|
325
|
+
const thirtyDaysAgo = new Date();
|
|
326
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
327
|
+
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`
|
|
330
|
+
.replace(/ +/g, ' ')
|
|
331
|
+
.trim();
|
|
332
|
+
try {
|
|
333
|
+
const { data } = await this.octokit.search.issuesAndPullRequests({
|
|
334
|
+
q: phase3Query,
|
|
335
|
+
sort: 'updated',
|
|
336
|
+
order: 'desc',
|
|
337
|
+
per_page: remainingNeeded * 3,
|
|
338
|
+
});
|
|
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
|
+
}
|
|
346
|
+
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
|
+
}
|
|
369
|
+
allCandidates.push(...starFiltered);
|
|
370
|
+
if (allVetFailed) {
|
|
371
|
+
phase3Error = 'all vetting failed';
|
|
372
|
+
}
|
|
373
|
+
if (vetRateLimitHit) {
|
|
374
|
+
rateLimitHitDuringSearch = true;
|
|
375
|
+
}
|
|
376
|
+
console.log(`Found ${starFiltered.length} candidates from maintained-repo search`);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
380
|
+
phase3Error = errorMessage;
|
|
381
|
+
if (IssueVetter.isRateLimitError(error)) {
|
|
382
|
+
rateLimitHitDuringSearch = true;
|
|
383
|
+
}
|
|
384
|
+
warn(MODULE, `Error in maintained-repo search: ${errorMessage}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (allCandidates.length === 0) {
|
|
388
|
+
const phaseErrors = [
|
|
389
|
+
phase0Error ? `Phase 0 (merged-PR repos): ${phase0Error}` : null,
|
|
390
|
+
phase1Error ? `Phase 1 (starred repos): ${phase1Error}` : null,
|
|
391
|
+
phase2Error ? `Phase 2 (general): ${phase2Error}` : null,
|
|
392
|
+
phase3Error ? `Phase 3 (maintained repos): ${phase3Error}` : null,
|
|
393
|
+
].filter(Boolean);
|
|
394
|
+
const details = phaseErrors.length > 0 ? ` ${phaseErrors.join('. ')}.` : '';
|
|
395
|
+
// When rate limits caused zero results, return empty array with warning
|
|
396
|
+
// instead of throwing, so callers can handle it gracefully
|
|
397
|
+
if (rateLimitHitDuringSearch) {
|
|
398
|
+
this.rateLimitWarning =
|
|
399
|
+
`Search returned no results due to GitHub API rate limits.${details} ` +
|
|
400
|
+
`Try again after the rate limit resets.`;
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
throw new ValidationError(`No issue candidates found across all search phases.${details} ` +
|
|
404
|
+
'Try adjusting your search criteria (languages, labels) or check your network connection.');
|
|
405
|
+
}
|
|
406
|
+
// Surface rate limit warning even with partial results (#100)
|
|
407
|
+
// This overwrites the pre-flight "quota low" warning (speculative) with a more
|
|
408
|
+
// informative "results incomplete" warning (factual) when rate limits actually hit.
|
|
409
|
+
if (rateLimitHitDuringSearch) {
|
|
410
|
+
this.rateLimitWarning =
|
|
411
|
+
`Search results may be incomplete: GitHub API rate limits were hit during search. ` +
|
|
412
|
+
`Found ${allCandidates.length} candidate${allCandidates.length === 1 ? '' : 's'} but some search phases failed. ` +
|
|
413
|
+
`Try again after the rate limit resets for complete results.`;
|
|
414
|
+
}
|
|
415
|
+
// Sort by priority first, then by recommendation, then by viability score
|
|
416
|
+
allCandidates.sort((a, b) => {
|
|
417
|
+
// Priority order: merged_pr > starred > normal
|
|
418
|
+
const priorityOrder = { merged_pr: 0, starred: 1, normal: 2 };
|
|
419
|
+
const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
|
|
420
|
+
if (priorityDiff !== 0)
|
|
421
|
+
return priorityDiff;
|
|
422
|
+
// Then by recommendation
|
|
423
|
+
const recommendationOrder = { approve: 0, needs_review: 1, skip: 2 };
|
|
424
|
+
const recDiff = recommendationOrder[a.recommendation] - recommendationOrder[b.recommendation];
|
|
425
|
+
if (recDiff !== 0)
|
|
426
|
+
return recDiff;
|
|
427
|
+
// Then by viability score (highest first)
|
|
428
|
+
return b.viabilityScore - a.viabilityScore;
|
|
429
|
+
});
|
|
430
|
+
// Apply per-repo cap: max 2 issues from any single repo (#105)
|
|
431
|
+
const capped = applyPerRepoCap(allCandidates, 2);
|
|
432
|
+
return capped.slice(0, maxResults);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Search for issues within specific repos using batched queries.
|
|
436
|
+
*
|
|
437
|
+
* To avoid GitHub's secondary rate limit (30 requests/minute), we batch
|
|
438
|
+
* multiple repos into a single search query using OR syntax:
|
|
439
|
+
* repo:owner1/repo1 OR repo:owner2/repo2 OR repo:owner3/repo3
|
|
440
|
+
*
|
|
441
|
+
* This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE).
|
|
442
|
+
*/
|
|
443
|
+
async searchInRepos(repos, baseQuery, maxResults, priority, filterFn) {
|
|
444
|
+
const candidates = [];
|
|
445
|
+
// Batch repos to reduce API calls.
|
|
446
|
+
// GitHub search query has a max length (~256 chars for query part).
|
|
447
|
+
// Each "repo:owner/repo" is ~20-40 chars, plus " OR " (4 chars).
|
|
448
|
+
// Using 5 repos per batch stays well under the limit.
|
|
449
|
+
const BATCH_SIZE = 5;
|
|
450
|
+
const batches = this.batchRepos(repos, BATCH_SIZE);
|
|
451
|
+
let failedBatches = 0;
|
|
452
|
+
let rateLimitFailures = 0;
|
|
453
|
+
for (const batch of batches) {
|
|
454
|
+
if (candidates.length >= maxResults)
|
|
455
|
+
break;
|
|
456
|
+
try {
|
|
457
|
+
// Build repo filter: (repo:a OR repo:b OR repo:c)
|
|
458
|
+
const repoFilter = batch.map((r) => `repo:${r}`).join(' OR ');
|
|
459
|
+
const batchQuery = `${baseQuery} (${repoFilter})`;
|
|
460
|
+
const { data } = await this.octokit.search.issuesAndPullRequests({
|
|
461
|
+
q: batchQuery,
|
|
462
|
+
sort: 'created',
|
|
463
|
+
order: 'desc',
|
|
464
|
+
per_page: Math.min(30, (maxResults - candidates.length) * 3),
|
|
465
|
+
});
|
|
466
|
+
if (data.items.length > 0) {
|
|
467
|
+
const filtered = filterFn(data.items);
|
|
468
|
+
const remainingNeeded = maxResults - candidates.length;
|
|
469
|
+
const { candidates: vetted } = await this.vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, priority);
|
|
470
|
+
candidates.push(...vetted);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
failedBatches++;
|
|
475
|
+
if (IssueVetter.isRateLimitError(error)) {
|
|
476
|
+
rateLimitFailures++;
|
|
477
|
+
}
|
|
478
|
+
const batchRepos = batch.join(', ');
|
|
479
|
+
warn(MODULE, `Error searching issues in batch [${batchRepos}]:`, error instanceof Error ? error.message : error);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const allBatchesFailed = failedBatches === batches.length && batches.length > 0;
|
|
483
|
+
const rateLimitHit = rateLimitFailures > 0;
|
|
484
|
+
if (allBatchesFailed) {
|
|
485
|
+
warn(MODULE, `All ${batches.length} batch(es) failed for ${priority} phase. ` +
|
|
486
|
+
`This may indicate a systemic issue (rate limit, auth, network).`);
|
|
487
|
+
}
|
|
488
|
+
return { candidates, allBatchesFailed, rateLimitHit };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Split repos into batches of the specified size.
|
|
492
|
+
*/
|
|
493
|
+
batchRepos(repos, batchSize) {
|
|
494
|
+
const batches = [];
|
|
495
|
+
for (let i = 0; i < repos.length; i += batchSize) {
|
|
496
|
+
batches.push(repos.slice(i, i + batchSize));
|
|
497
|
+
}
|
|
498
|
+
return batches;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Check if an error is a GitHub rate limit error (429 or rate-limit 403).
|
|
502
|
+
* Static proxy kept for backward compatibility with tests.
|
|
503
|
+
*/
|
|
504
|
+
static isRateLimitError(error) {
|
|
505
|
+
return IssueVetter.isRateLimitError(error);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Vet a specific issue (delegates to IssueVetter).
|
|
509
|
+
*/
|
|
510
|
+
async vetIssue(issueUrl) {
|
|
511
|
+
return this.vetter.vetIssue(issueUrl);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Analyze issue requirements for clarity (delegates to IssueVetter).
|
|
515
|
+
* Kept on class for backward compatibility.
|
|
516
|
+
*/
|
|
517
|
+
analyzeRequirements(body) {
|
|
518
|
+
return this.vetter.analyzeRequirements(body);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Calculate viability score for an issue (delegates to issue-scoring module).
|
|
522
|
+
* Kept on class for backward compatibility with tests that call instance.calculateViabilityScore().
|
|
523
|
+
*/
|
|
524
|
+
calculateViabilityScore(params) {
|
|
525
|
+
return calcViabilityScore(params);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Save search results to ~/.oss-autopilot/found-issues.md
|
|
529
|
+
* Results are sorted by viability score (highest first)
|
|
530
|
+
*/
|
|
531
|
+
saveSearchResults(candidates) {
|
|
532
|
+
// Sort by viability score descending
|
|
533
|
+
const sorted = [...candidates].sort((a, b) => b.viabilityScore - a.viabilityScore);
|
|
534
|
+
const outputDir = getDataDir();
|
|
535
|
+
const outputFile = path.join(outputDir, 'found-issues.md');
|
|
536
|
+
// Directory is created by getDataDir() if needed
|
|
537
|
+
// Generate markdown content
|
|
538
|
+
const timestamp = new Date().toISOString();
|
|
539
|
+
let content = `# Found Issues\n\n`;
|
|
540
|
+
content += `> Generated at: ${timestamp}\n\n`;
|
|
541
|
+
content += `| Score | Repo | Issue | Title | Labels | Updated | Recommendation |\n`;
|
|
542
|
+
content += `|-------|------|-------|-------|--------|---------|----------------|\n`;
|
|
543
|
+
for (const candidate of sorted) {
|
|
544
|
+
const { issue, viabilityScore, recommendation } = candidate;
|
|
545
|
+
const labels = issue.labels.slice(0, 3).join(', ');
|
|
546
|
+
const truncatedLabels = labels.length > 30 ? labels.substring(0, 27) + '...' : labels;
|
|
547
|
+
const truncatedTitle = issue.title.length > 50 ? issue.title.substring(0, 47) + '...' : issue.title;
|
|
548
|
+
const updatedDate = new Date(issue.updatedAt).toLocaleDateString();
|
|
549
|
+
const recIcon = recommendation === 'approve' ? 'Y' : recommendation === 'skip' ? 'N' : '?';
|
|
550
|
+
content += `| ${viabilityScore} | ${issue.repo} | [#${issue.number}](${issue.url}) | ${truncatedTitle} | ${truncatedLabels} | ${updatedDate} | ${recIcon} |\n`;
|
|
551
|
+
}
|
|
552
|
+
content += `\n## Legend\n\n`;
|
|
553
|
+
content += `- **Score**: Viability score (0-100)\n`;
|
|
554
|
+
content += `- **Recommendation**: Y = approve, N = skip, ? = needs_review\n`;
|
|
555
|
+
fs.writeFileSync(outputFile, content, 'utf-8');
|
|
556
|
+
console.log(`Saved ${sorted.length} issues to ${outputFile}`);
|
|
557
|
+
return outputFile;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Format issue candidate for display
|
|
561
|
+
*/
|
|
562
|
+
formatCandidate(candidate) {
|
|
563
|
+
const { issue, vettingResult, projectHealth, recommendation, reasonsToApprove, reasonsToSkip } = candidate;
|
|
564
|
+
const statusIcon = recommendation === 'approve' ? '✅' : recommendation === 'skip' ? '❌' : '⚠️';
|
|
565
|
+
return `
|
|
566
|
+
## ${statusIcon} Issue Candidate: ${issue.repo}#${issue.number}
|
|
567
|
+
|
|
568
|
+
**Title:** ${issue.title}
|
|
569
|
+
**Labels:** ${issue.labels.join(', ')}
|
|
570
|
+
**Created:** ${new Date(issue.createdAt).toLocaleDateString()}
|
|
571
|
+
**URL:** ${issue.url}
|
|
572
|
+
|
|
573
|
+
### Vetting Results
|
|
574
|
+
${Object.entries(vettingResult.checks)
|
|
575
|
+
.map(([key, passed]) => `- ${passed ? '✓' : '✗'} ${key.replace(/([A-Z])/g, ' $1').toLowerCase()}`)
|
|
576
|
+
.join('\n')}
|
|
577
|
+
|
|
578
|
+
### Project Health
|
|
579
|
+
- Last commit: ${projectHealth.checkFailed ? 'unknown (API error)' : `${projectHealth.daysSinceLastCommit} days ago`}
|
|
580
|
+
- Open issues: ${projectHealth.openIssuesCount}
|
|
581
|
+
- CI status: ${projectHealth.ciStatus}
|
|
582
|
+
|
|
583
|
+
### Recommendation: **${recommendation.toUpperCase()}**
|
|
584
|
+
${reasonsToApprove.length > 0 ? `\n**Reasons to approve:**\n${reasonsToApprove.map((r) => `- ${r}`).join('\n')}` : ''}
|
|
585
|
+
${reasonsToSkip.length > 0 ? `\n**Reasons to skip:**\n${reasonsToSkip.map((r) => `- ${r}`).join('\n')}` : ''}
|
|
586
|
+
${vettingResult.notes.length > 0 ? `\n**Notes:**\n${vettingResult.notes.map((n) => `- ${n}`).join('\n')}` : ''}
|
|
587
|
+
`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Filtering — pure functions for filtering and spam detection on search results.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from issue-discovery.ts (#356) to isolate filtering logic:
|
|
5
|
+
* label farming detection, doc-only filtering, per-repo caps, templated title detection.
|
|
6
|
+
*/
|
|
7
|
+
/** Minimal shape of a GitHub search result item (from octokit.search.issuesAndPullRequests) */
|
|
8
|
+
export interface GitHubSearchItem {
|
|
9
|
+
html_url: string;
|
|
10
|
+
repository_url: string;
|
|
11
|
+
updated_at: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
labels?: Array<{
|
|
14
|
+
name?: string;
|
|
15
|
+
} | string>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
/** Labels that indicate documentation-only issues (#105). */
|
|
19
|
+
export declare const DOC_ONLY_LABELS: Set<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Check if an issue's labels are ALL documentation-related (#105).
|
|
22
|
+
* Issues with mixed labels (e.g., "good first issue" + "documentation") pass through.
|
|
23
|
+
* Issues with no labels are not considered doc-only.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isDocOnlyIssue(item: GitHubSearchItem): boolean;
|
|
26
|
+
/** Known beginner-type label names used to detect label-farming repos (#97). */
|
|
27
|
+
export declare const BEGINNER_LABELS: Set<string>;
|
|
28
|
+
/** Check if a single issue has an excessive number of beginner labels (>= 5). */
|
|
29
|
+
export declare function isLabelFarming(item: GitHubSearchItem): boolean;
|
|
30
|
+
/** Detect mass-created issue titles like "Add Trivia Question 61" or "Create Entry #5". */
|
|
31
|
+
export declare function hasTemplatedTitle(title: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Batch-analyze search items to detect label-farming repositories (#97).
|
|
34
|
+
* Returns a Set of repo full names (owner/repo) that appear to be spam.
|
|
35
|
+
*
|
|
36
|
+
* A repo is flagged if:
|
|
37
|
+
* - ANY single issue has >= 5 beginner labels (strong individual signal), OR
|
|
38
|
+
* - It has >= 3 issues with templated titles (batch signal)
|
|
39
|
+
*/
|
|
40
|
+
export declare function detectLabelFarmingRepos(items: GitHubSearchItem[]): Set<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Apply per-repo cap to candidates (#105).
|
|
43
|
+
* Keeps at most `maxPerRepo` issues from any single repo.
|
|
44
|
+
* Maintains the existing sort order — first N from each repo are kept,
|
|
45
|
+
* excess issues from over-represented repos are dropped.
|
|
46
|
+
*/
|
|
47
|
+
export declare function applyPerRepoCap<T extends {
|
|
48
|
+
issue: {
|
|
49
|
+
repo: string;
|
|
50
|
+
};
|
|
51
|
+
}>(candidates: T[], maxPerRepo: number): T[];
|