@oss-autopilot/core 1.11.0 → 1.12.1
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/README.md +8 -5
- package/dist/cli.bundle.cjs +67 -108
- package/dist/commands/daily.js +17 -0
- package/dist/commands/index.d.ts +3 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/scout-bridge.d.ts +15 -0
- package/dist/commands/scout-bridge.js +63 -0
- package/dist/commands/search.d.ts +1 -1
- package/dist/commands/search.js +10 -13
- package/dist/commands/vet-list.d.ts +1 -1
- package/dist/commands/vet-list.js +4 -5
- package/dist/commands/vet.d.ts +1 -1
- package/dist/commands/vet.js +4 -5
- package/dist/core/index.d.ts +0 -2
- package/dist/core/index.js +1 -2
- package/package.json +2 -1
- package/dist/core/category-mapping.d.ts +0 -19
- package/dist/core/category-mapping.js +0 -58
- package/dist/core/issue-discovery.d.ts +0 -94
- package/dist/core/issue-discovery.js +0 -591
- package/dist/core/issue-eligibility.d.ts +0 -38
- package/dist/core/issue-eligibility.js +0 -151
- package/dist/core/issue-filtering.d.ts +0 -51
- package/dist/core/issue-filtering.js +0 -103
- package/dist/core/issue-scoring.d.ts +0 -43
- package/dist/core/issue-scoring.js +0 -97
- package/dist/core/issue-vetting.d.ts +0 -33
- package/dist/core/issue-vetting.js +0 -306
- package/dist/core/repo-health.d.ts +0 -24
- package/dist/core/repo-health.js +0 -194
- package/dist/core/search-budget.d.ts +0 -62
- package/dist/core/search-budget.js +0 -129
- package/dist/core/search-phases.d.ts +0 -83
- package/dist/core/search-phases.js +0 -238
|
@@ -1,591 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Issue Discovery — orchestrates multi-phase issue search across GitHub.
|
|
3
|
-
*
|
|
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
|
|
11
|
-
*/
|
|
12
|
-
import * as fs from 'fs';
|
|
13
|
-
import * as path from 'path';
|
|
14
|
-
import { getOctokit, checkRateLimit } from './github.js';
|
|
15
|
-
import { getStateManager } from './state.js';
|
|
16
|
-
import { getSearchBudgetTracker } from './search-budget.js';
|
|
17
|
-
import { daysBetween, getDataDir, sleep } from './utils.js';
|
|
18
|
-
import { DEFAULT_CONFIG, SCOPE_LABELS } from './types.js';
|
|
19
|
-
import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError } from './errors.js';
|
|
20
|
-
import { debug, info, warn } from './logger.js';
|
|
21
|
-
import { isDocOnlyIssue, applyPerRepoCap } from './issue-filtering.js';
|
|
22
|
-
import { IssueVetter } from './issue-vetting.js';
|
|
23
|
-
import { getTopicsForCategories } from './category-mapping.js';
|
|
24
|
-
import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, filterVetAndScore, searchInRepos, searchWithChunkedLabels, } from './search-phases.js';
|
|
25
|
-
const MODULE = 'issue-discovery';
|
|
26
|
-
/** Delay between major search phases to let GitHub's rate limit window cool down. */
|
|
27
|
-
const INTER_PHASE_DELAY_MS = 2000;
|
|
28
|
-
/** If remaining search quota is below this, skip heavy phases (2, 3). */
|
|
29
|
-
const LOW_BUDGET_THRESHOLD = 20;
|
|
30
|
-
/** If remaining search quota is below this, only run Phase 0. */
|
|
31
|
-
const CRITICAL_BUDGET_THRESHOLD = 10;
|
|
32
|
-
/**
|
|
33
|
-
* Multi-phase issue discovery engine that searches GitHub for contributable issues.
|
|
34
|
-
*
|
|
35
|
-
* Search phases (in priority order):
|
|
36
|
-
* 0. Repos where user has merged PRs (highest merge probability)
|
|
37
|
-
* 0.5. Preferred organizations
|
|
38
|
-
* 1. Starred repos
|
|
39
|
-
* 2. General label-filtered search
|
|
40
|
-
* 3. Actively maintained repos
|
|
41
|
-
*
|
|
42
|
-
* Each candidate is vetted for claimability and scored 0-100 for viability.
|
|
43
|
-
*/
|
|
44
|
-
export class IssueDiscovery {
|
|
45
|
-
octokit;
|
|
46
|
-
stateManager;
|
|
47
|
-
githubToken;
|
|
48
|
-
vetter;
|
|
49
|
-
/** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
|
|
50
|
-
rateLimitWarning = null;
|
|
51
|
-
/** @param githubToken - GitHub personal access token or token from `gh auth token` */
|
|
52
|
-
constructor(githubToken) {
|
|
53
|
-
this.githubToken = githubToken;
|
|
54
|
-
this.octokit = getOctokit(githubToken);
|
|
55
|
-
this.stateManager = getStateManager();
|
|
56
|
-
this.vetter = new IssueVetter(this.octokit, this.stateManager);
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Fetch the authenticated user's starred repositories from GitHub.
|
|
60
|
-
* Updates the state manager with the list and timestamp.
|
|
61
|
-
* @returns Array of starred repo names in "owner/repo" format
|
|
62
|
-
*/
|
|
63
|
-
async fetchStarredRepos() {
|
|
64
|
-
info(MODULE, 'Fetching starred repositories...');
|
|
65
|
-
const starredRepos = [];
|
|
66
|
-
try {
|
|
67
|
-
// Paginate through all starred repos (up to 500 to avoid excessive API calls)
|
|
68
|
-
const iterator = this.octokit.paginate.iterator(this.octokit.activity.listReposStarredByAuthenticatedUser, {
|
|
69
|
-
per_page: 100,
|
|
70
|
-
});
|
|
71
|
-
let pageCount = 0;
|
|
72
|
-
for await (const { data: repos } of iterator) {
|
|
73
|
-
for (const repo of repos) {
|
|
74
|
-
// Handle both Repository and StarredRepository response types
|
|
75
|
-
// Repository has full_name directly, StarredRepository has { repo: Repository }
|
|
76
|
-
let fullName;
|
|
77
|
-
if ('full_name' in repo && typeof repo.full_name === 'string') {
|
|
78
|
-
// Repository type - full_name is directly on the object
|
|
79
|
-
fullName = repo.full_name;
|
|
80
|
-
}
|
|
81
|
-
else if ('repo' in repo && repo.repo && typeof repo.repo === 'object' && 'full_name' in repo.repo) {
|
|
82
|
-
// StarredRepository type - full_name is nested in repo property
|
|
83
|
-
fullName = repo.repo.full_name;
|
|
84
|
-
}
|
|
85
|
-
if (fullName) {
|
|
86
|
-
starredRepos.push(fullName);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
pageCount++;
|
|
90
|
-
// Limit to 5 pages (500 repos) to avoid excessive API usage
|
|
91
|
-
if (pageCount >= 5) {
|
|
92
|
-
info(MODULE, 'Reached pagination limit for starred repos (500)');
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
info(MODULE, `Fetched ${starredRepos.length} starred repositories`);
|
|
97
|
-
this.stateManager.setStarredRepos(starredRepos);
|
|
98
|
-
return starredRepos;
|
|
99
|
-
}
|
|
100
|
-
catch (error) {
|
|
101
|
-
const cachedRepos = this.stateManager.getStarredRepos();
|
|
102
|
-
const errMsg = errorMessage(error);
|
|
103
|
-
warn(MODULE, 'Error fetching starred repos:', errMsg);
|
|
104
|
-
if (cachedRepos.length === 0) {
|
|
105
|
-
warn(MODULE, `Failed to fetch starred repositories from GitHub API. ` +
|
|
106
|
-
`No cached repos available. Error: ${errMsg}\n` +
|
|
107
|
-
`Tip: Ensure your GITHUB_TOKEN has the 'read:user' scope and try again.`);
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
warn(MODULE, `Failed to fetch starred repositories from GitHub API. ` +
|
|
111
|
-
`Using ${cachedRepos.length} cached repos instead. Error: ${errMsg}`);
|
|
112
|
-
}
|
|
113
|
-
return cachedRepos;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Get starred repos, fetching from GitHub if cache is stale.
|
|
118
|
-
* @returns Array of starred repo names in "owner/repo" format
|
|
119
|
-
*/
|
|
120
|
-
async getStarredReposWithRefresh() {
|
|
121
|
-
if (this.stateManager.isStarredReposStale()) {
|
|
122
|
-
return this.fetchStarredRepos();
|
|
123
|
-
}
|
|
124
|
-
return this.stateManager.getStarredRepos();
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Search for issues matching our criteria.
|
|
128
|
-
* Searches in priority order: merged-PR repos first (no label filter), then preferred
|
|
129
|
-
* organizations, then starred repos, then general search, then actively maintained repos.
|
|
130
|
-
* Filters out issues from low-scoring and excluded repos.
|
|
131
|
-
*
|
|
132
|
-
* @param options - Search configuration
|
|
133
|
-
* @param options.languages - Programming languages to filter by
|
|
134
|
-
* @param options.labels - Issue labels to search for
|
|
135
|
-
* @param options.maxResults - Maximum candidates to return (default: 10)
|
|
136
|
-
* @returns Scored and sorted issue candidates
|
|
137
|
-
* @throws {ValidationError} If no candidates found and no rate limits prevented the search
|
|
138
|
-
*
|
|
139
|
-
* @example
|
|
140
|
-
* ```typescript
|
|
141
|
-
* import { IssueDiscovery, requireGitHubToken } from '@oss-autopilot/core';
|
|
142
|
-
*
|
|
143
|
-
* const discovery = new IssueDiscovery(requireGitHubToken());
|
|
144
|
-
* const candidates = await discovery.searchIssues({ maxResults: 5 });
|
|
145
|
-
* for (const c of candidates) {
|
|
146
|
-
* console.log(`${c.issue.repo}#${c.issue.number}: ${c.viabilityScore}/100`);
|
|
147
|
-
* }
|
|
148
|
-
* ```
|
|
149
|
-
*/
|
|
150
|
-
async searchIssues(options = {}) {
|
|
151
|
-
const config = this.stateManager.getState().config;
|
|
152
|
-
const languages = options.languages || config.languages;
|
|
153
|
-
const scopes = config.scope; // undefined = legacy mode
|
|
154
|
-
const labels = options.labels || (scopes ? buildEffectiveLabels(scopes, config.labels) : config.labels);
|
|
155
|
-
const maxResults = options.maxResults || 10;
|
|
156
|
-
const minStars = config.minStars ?? 50;
|
|
157
|
-
const allCandidates = [];
|
|
158
|
-
let phase0Error = null;
|
|
159
|
-
let phase1Error = null;
|
|
160
|
-
let rateLimitHitDuringSearch = false;
|
|
161
|
-
// Pre-flight rate limit check (#100) — also determines adaptive phase budget
|
|
162
|
-
this.rateLimitWarning = null;
|
|
163
|
-
const tracker = getSearchBudgetTracker();
|
|
164
|
-
let searchBudget = LOW_BUDGET_THRESHOLD - 1; // conservative: below threshold to skip heavy phases
|
|
165
|
-
try {
|
|
166
|
-
const rateLimit = await checkRateLimit(this.githubToken);
|
|
167
|
-
searchBudget = rateLimit.remaining;
|
|
168
|
-
tracker.init(rateLimit.remaining, rateLimit.resetAt);
|
|
169
|
-
if (rateLimit.remaining < 5) {
|
|
170
|
-
const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString('en-US', { hour12: false });
|
|
171
|
-
this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
|
|
172
|
-
warn(MODULE, this.rateLimitWarning);
|
|
173
|
-
}
|
|
174
|
-
if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
|
|
175
|
-
info(MODULE, `Search budget critical (${searchBudget} remaining) — running only Phase 0`);
|
|
176
|
-
}
|
|
177
|
-
else if (searchBudget < LOW_BUDGET_THRESHOLD) {
|
|
178
|
-
info(MODULE, `Search budget low (${searchBudget} remaining) — skipping heavy phases (2, 3)`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
182
|
-
// Fail fast on auth errors — no point searching with a bad token
|
|
183
|
-
if (getHttpStatusCode(error) === 401) {
|
|
184
|
-
throw error;
|
|
185
|
-
}
|
|
186
|
-
// Non-fatal: proceed with conservative budget for transient/network errors.
|
|
187
|
-
// Initialize tracker with conservative defaults so it doesn't fly blind.
|
|
188
|
-
tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 60000).toISOString());
|
|
189
|
-
warn(MODULE, 'Could not check rate limit — using conservative budget, skipping heavy phases:', errorMessage(error));
|
|
190
|
-
}
|
|
191
|
-
// Get merged-PR repos (highest merge probability)
|
|
192
|
-
const mergedPRRepos = this.stateManager.getReposWithMergedPRs();
|
|
193
|
-
const mergedPRRepoSet = new Set(mergedPRRepos);
|
|
194
|
-
// Get open-PR repos (repos with score data but no merges yet)
|
|
195
|
-
const openPRRepos = this.stateManager.getReposWithOpenPRs();
|
|
196
|
-
// Get starred repos (with refresh if stale)
|
|
197
|
-
const starredRepos = await this.getStarredReposWithRefresh();
|
|
198
|
-
const starredRepoSet = new Set(starredRepos);
|
|
199
|
-
// Get low-scoring repos from state
|
|
200
|
-
const lowScoringRepos = new Set(this.stateManager.getLowScoringRepos(3)); // Score <= 3 is low
|
|
201
|
-
// Common filters
|
|
202
|
-
const trackedUrls = new Set(this.stateManager.getState().activeIssues.map((i) => i.url));
|
|
203
|
-
const excludedRepos = new Set(config.excludeRepos);
|
|
204
|
-
const maxAgeDays = config.maxIssueAgeDays || 90;
|
|
205
|
-
const now = new Date();
|
|
206
|
-
// Build query parts
|
|
207
|
-
// When languages includes 'any', omit the language filter entirely
|
|
208
|
-
const isAnyLanguage = languages.some((l) => l.toLowerCase() === 'any');
|
|
209
|
-
const langQuery = isAnyLanguage ? '' : languages.map((l) => `language:${l}`).join(' ');
|
|
210
|
-
// Phase 0 uses a broader query — established contributors don't need beginner labels
|
|
211
|
-
// Phases 1+ pass labels separately to searchInRepos/searchWithChunkedLabels
|
|
212
|
-
const baseQualifiers = `is:issue is:open ${langQuery} no:assignee`.replace(/ +/g, ' ').trim();
|
|
213
|
-
// Helper to filter issues
|
|
214
|
-
const includeDocIssues = config.includeDocIssues ?? true;
|
|
215
|
-
const aiBlocklisted = new Set(config.aiPolicyBlocklist ?? DEFAULT_CONFIG.aiPolicyBlocklist ?? []);
|
|
216
|
-
if (aiBlocklisted.size > 0) {
|
|
217
|
-
debug(MODULE, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(', ')}`);
|
|
218
|
-
}
|
|
219
|
-
const filterIssues = (items) => {
|
|
220
|
-
return items.filter((item) => {
|
|
221
|
-
if (trackedUrls.has(item.html_url))
|
|
222
|
-
return false;
|
|
223
|
-
const repoFullName = item.repository_url.split('/').slice(-2).join('/');
|
|
224
|
-
if (excludedRepos.has(repoFullName))
|
|
225
|
-
return false;
|
|
226
|
-
// Filter repos with known anti-AI contribution policies (#108)
|
|
227
|
-
if (aiBlocklisted.has(repoFullName))
|
|
228
|
-
return false;
|
|
229
|
-
// Filter OUT low-scoring repos
|
|
230
|
-
if (lowScoringRepos.has(repoFullName))
|
|
231
|
-
return false;
|
|
232
|
-
// Filter by issue age based on updated_at
|
|
233
|
-
const updatedAt = new Date(item.updated_at);
|
|
234
|
-
const ageDays = daysBetween(updatedAt, now);
|
|
235
|
-
if (ageDays > maxAgeDays)
|
|
236
|
-
return false;
|
|
237
|
-
// Filter out doc-only issues unless opted in (#105)
|
|
238
|
-
if (!includeDocIssues && isDocOnlyIssue(item))
|
|
239
|
-
return false;
|
|
240
|
-
return true;
|
|
241
|
-
});
|
|
242
|
-
};
|
|
243
|
-
// Phase 0: Search repos where user has merged PRs + open-PR repos (highest merge probability)
|
|
244
|
-
const phase0Repos = [...mergedPRRepos, ...openPRRepos.filter((r) => !mergedPRRepoSet.has(r))].slice(0, 10);
|
|
245
|
-
const phase0RepoSet = new Set(phase0Repos);
|
|
246
|
-
if (phase0Repos.length > 0) {
|
|
247
|
-
const mergedInPhase0 = Math.min(mergedPRRepos.length, phase0Repos.length);
|
|
248
|
-
const openInPhase0 = phase0Repos.length - mergedInPhase0;
|
|
249
|
-
info(MODULE, `Phase 0: Searching issues in ${phase0Repos.length} repos (${mergedInPhase0} merged-PR, ${openInPhase0} open-PR, no label filter)...`);
|
|
250
|
-
// Phase 0a: merged-PR repos (priority: merged_pr)
|
|
251
|
-
const mergedPhase0Repos = phase0Repos.slice(0, mergedInPhase0);
|
|
252
|
-
if (mergedPhase0Repos.length > 0) {
|
|
253
|
-
const remainingNeeded = maxResults - allCandidates.length;
|
|
254
|
-
if (remainingNeeded > 0) {
|
|
255
|
-
const { candidates: mergedCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, mergedPhase0Repos, baseQualifiers, [], remainingNeeded, 'merged_pr', filterIssues);
|
|
256
|
-
allCandidates.push(...mergedCandidates);
|
|
257
|
-
if (allBatchesFailed) {
|
|
258
|
-
phase0Error = 'All merged-PR repo batches failed';
|
|
259
|
-
}
|
|
260
|
-
if (rateLimitHit) {
|
|
261
|
-
rateLimitHitDuringSearch = true;
|
|
262
|
-
}
|
|
263
|
-
info(MODULE, `Found ${mergedCandidates.length} candidates from merged-PR repos`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// Phase 0b: open-PR repos (priority: starred — intermediate tier)
|
|
267
|
-
const openPhase0Repos = phase0Repos.slice(mergedInPhase0);
|
|
268
|
-
if (openPhase0Repos.length > 0 && allCandidates.length < maxResults) {
|
|
269
|
-
const remainingNeeded = maxResults - allCandidates.length;
|
|
270
|
-
if (remainingNeeded > 0) {
|
|
271
|
-
const { candidates: openCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, openPhase0Repos, baseQualifiers, [], remainingNeeded, 'starred', filterIssues);
|
|
272
|
-
allCandidates.push(...openCandidates);
|
|
273
|
-
if (allBatchesFailed) {
|
|
274
|
-
const msg = 'All open-PR repo batches failed';
|
|
275
|
-
phase0Error = phase0Error ? `${phase0Error}; ${msg}` : msg;
|
|
276
|
-
}
|
|
277
|
-
if (rateLimitHit) {
|
|
278
|
-
rateLimitHitDuringSearch = true;
|
|
279
|
-
}
|
|
280
|
-
info(MODULE, `Found ${openCandidates.length} candidates from open-PR repos`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
// Phase 0.5: Search preferred organizations (explicit user preference)
|
|
285
|
-
// Skip if budget is critical — Phase 0 results are sufficient
|
|
286
|
-
let phase0_5Error = null;
|
|
287
|
-
const preferredOrgs = config.preferredOrgs ?? [];
|
|
288
|
-
if (allCandidates.length < maxResults && preferredOrgs.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD) {
|
|
289
|
-
// Inter-phase delay to let GitHub's rate limit window cool down
|
|
290
|
-
if (phase0Repos.length > 0)
|
|
291
|
-
await sleep(INTER_PHASE_DELAY_MS);
|
|
292
|
-
// Filter out orgs already covered by Phase 0 repos
|
|
293
|
-
const phase0Orgs = new Set(phase0Repos.map((r) => r.split('/')[0]?.toLowerCase()));
|
|
294
|
-
const orgsToSearch = preferredOrgs.filter((org) => !phase0Orgs.has(org.toLowerCase())).slice(0, 5);
|
|
295
|
-
if (orgsToSearch.length > 0) {
|
|
296
|
-
info(MODULE, `Phase 0.5: Searching issues in ${orgsToSearch.length} preferred org(s)...`);
|
|
297
|
-
const remainingNeeded = maxResults - allCandidates.length;
|
|
298
|
-
const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(' OR ');
|
|
299
|
-
const orgOps = orgsToSearch.length - 1;
|
|
300
|
-
try {
|
|
301
|
-
const allItems = await searchWithChunkedLabels(this.octokit, labels, orgOps, (labelQ) => `${baseQualifiers} ${labelQ} (${orgRepoFilter})`.replace(/ +/g, ' ').trim(), remainingNeeded * 3);
|
|
302
|
-
if (allItems.length > 0) {
|
|
303
|
-
const filtered = filterIssues(allItems).filter((item) => {
|
|
304
|
-
const repoFullName = item.repository_url.split('/').slice(-2).join('/');
|
|
305
|
-
return !phase0RepoSet.has(repoFullName);
|
|
306
|
-
});
|
|
307
|
-
const { candidates: orgCandidates, allFailed: allVetFailed, rateLimitHit, } = await this.vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, 'preferred_org');
|
|
308
|
-
allCandidates.push(...orgCandidates);
|
|
309
|
-
if (allVetFailed) {
|
|
310
|
-
phase0_5Error = 'All preferred org issue vetting failed';
|
|
311
|
-
}
|
|
312
|
-
if (rateLimitHit) {
|
|
313
|
-
rateLimitHitDuringSearch = true;
|
|
314
|
-
}
|
|
315
|
-
info(MODULE, `Found ${orgCandidates.length} candidates from preferred orgs`);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
catch (error) {
|
|
319
|
-
const errMsg = errorMessage(error);
|
|
320
|
-
phase0_5Error = errMsg;
|
|
321
|
-
if (isRateLimitError(error)) {
|
|
322
|
-
rateLimitHitDuringSearch = true;
|
|
323
|
-
}
|
|
324
|
-
warn(MODULE, `Error searching preferred orgs: ${errMsg}`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
// Phase 1: Search starred repos (filter out already-searched Phase 0 repos)
|
|
329
|
-
// Skip if budget is critical
|
|
330
|
-
if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD) {
|
|
331
|
-
await sleep(INTER_PHASE_DELAY_MS);
|
|
332
|
-
const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
|
|
333
|
-
if (reposToSearch.length > 0) {
|
|
334
|
-
info(MODULE, `Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
|
|
335
|
-
const remainingNeeded = maxResults - allCandidates.length;
|
|
336
|
-
if (remainingNeeded > 0) {
|
|
337
|
-
// Cap labels to reduce Search API calls: starred repos already signal user
|
|
338
|
-
// interest, so fewer labels suffice. With 3 labels and batch size 3 (2 repo ORs),
|
|
339
|
-
// each batch fits in a single label chunk instead of 3+, cutting Phase 1 calls
|
|
340
|
-
// from ~12 to ~4.
|
|
341
|
-
const phase1Labels = labels.slice(0, 3);
|
|
342
|
-
const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, reposToSearch.slice(0, 10), baseQualifiers, phase1Labels, remainingNeeded, 'starred', filterIssues);
|
|
343
|
-
allCandidates.push(...starredCandidates);
|
|
344
|
-
if (allBatchesFailed) {
|
|
345
|
-
phase1Error = 'All starred repo batches failed';
|
|
346
|
-
}
|
|
347
|
-
if (rateLimitHit) {
|
|
348
|
-
rateLimitHitDuringSearch = true;
|
|
349
|
-
}
|
|
350
|
-
info(MODULE, `Found ${starredCandidates.length} candidates from starred repos`);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
// Phase 2: General search (if still need more)
|
|
355
|
-
// Skip if budget is low — Phases 0, 0.5, 1 are cheaper and higher-value
|
|
356
|
-
// When multiple scope tiers are active, fire one query per tier and interleave
|
|
357
|
-
// results to prevent high-volume tiers (e.g., "enhancement") from drowning out
|
|
358
|
-
// beginner results.
|
|
359
|
-
let phase2Error = null;
|
|
360
|
-
if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD) {
|
|
361
|
-
await sleep(INTER_PHASE_DELAY_MS);
|
|
362
|
-
info(MODULE, 'Phase 2: General issue search...');
|
|
363
|
-
const remainingNeeded = maxResults - allCandidates.length;
|
|
364
|
-
const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
|
|
365
|
-
// Build per-tier label groups. Multi-tier when 2+ scopes; single-tier otherwise.
|
|
366
|
-
const tierLabelGroups = [];
|
|
367
|
-
if (scopes && scopes.length > 1) {
|
|
368
|
-
for (const scope of scopes) {
|
|
369
|
-
const scopeLabels = SCOPE_LABELS[scope] ?? [];
|
|
370
|
-
if (scopeLabels.length === 0) {
|
|
371
|
-
warn(MODULE, `Scope "${scope}" has no labels, skipping tier`);
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
tierLabelGroups.push({ tier: scope, tierLabels: scopeLabels });
|
|
375
|
-
}
|
|
376
|
-
// Custom labels not in any tier get their own pseudo-tier
|
|
377
|
-
const allScopeLabels = new Set(scopes.flatMap((s) => SCOPE_LABELS[s] ?? []));
|
|
378
|
-
const customOnly = config.labels.filter((l) => !allScopeLabels.has(l));
|
|
379
|
-
if (customOnly.length > 0) {
|
|
380
|
-
tierLabelGroups.push({ tier: 'custom', tierLabels: customOnly });
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
tierLabelGroups.push({ tier: 'general', tierLabels: labels });
|
|
385
|
-
}
|
|
386
|
-
const budgetPerTier = Math.ceil(remainingNeeded / tierLabelGroups.length);
|
|
387
|
-
const tierResults = [];
|
|
388
|
-
for (const { tier, tierLabels } of tierLabelGroups) {
|
|
389
|
-
try {
|
|
390
|
-
const allItems = await searchWithChunkedLabels(this.octokit, tierLabels, 0, // no repo/org ORs in Phase 2
|
|
391
|
-
(labelQ) => `${baseQualifiers} ${labelQ}`.replace(/ +/g, ' ').trim(), budgetPerTier * 3);
|
|
392
|
-
info(MODULE, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
|
|
393
|
-
const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(this.vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
|
|
394
|
-
tierResults.push(tierCandidates);
|
|
395
|
-
// Update seenRepos so later tiers don't return duplicate repos
|
|
396
|
-
for (const c of tierCandidates)
|
|
397
|
-
seenRepos.add(c.issue.repo);
|
|
398
|
-
if (allVetFailed) {
|
|
399
|
-
phase2Error = (phase2Error ? phase2Error + '; ' : '') + `${tier}: all vetting failed`;
|
|
400
|
-
}
|
|
401
|
-
if (vetRateLimitHit) {
|
|
402
|
-
rateLimitHitDuringSearch = true;
|
|
403
|
-
}
|
|
404
|
-
info(MODULE, `Found ${tierCandidates.length} candidates from ${tier} tier`);
|
|
405
|
-
}
|
|
406
|
-
catch (error) {
|
|
407
|
-
if (getHttpStatusCode(error) === 401)
|
|
408
|
-
throw error;
|
|
409
|
-
const errMsg = errorMessage(error);
|
|
410
|
-
phase2Error = (phase2Error ? phase2Error + '; ' : '') + `${tier}: ${errMsg}`;
|
|
411
|
-
if (isRateLimitError(error)) {
|
|
412
|
-
rateLimitHitDuringSearch = true;
|
|
413
|
-
}
|
|
414
|
-
warn(MODULE, `Error in ${tier} tier search: ${errMsg}`);
|
|
415
|
-
tierResults.push([]);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
const interleaved = interleaveArrays(tierResults);
|
|
419
|
-
if (interleaved.length === 0 && phase2Error) {
|
|
420
|
-
warn(MODULE, `All ${tierLabelGroups.length} scope tiers failed in Phase 2: ${phase2Error}`);
|
|
421
|
-
}
|
|
422
|
-
allCandidates.push(...interleaved.slice(0, remainingNeeded));
|
|
423
|
-
}
|
|
424
|
-
// Phase 3: Actively maintained repos (#349)
|
|
425
|
-
// Skip if budget is low — this phase is API-heavy with broad queries
|
|
426
|
-
let phase3Error = null;
|
|
427
|
-
if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD) {
|
|
428
|
-
await sleep(INTER_PHASE_DELAY_MS);
|
|
429
|
-
info(MODULE, 'Phase 3: Searching actively maintained repos...');
|
|
430
|
-
const remainingNeeded = maxResults - allCandidates.length;
|
|
431
|
-
const thirtyDaysAgo = new Date();
|
|
432
|
-
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
433
|
-
const pushedSince = thirtyDaysAgo.toISOString().split('T')[0];
|
|
434
|
-
const categoryTopics = getTopicsForCategories(config.projectCategories ?? []);
|
|
435
|
-
const topicQuery = categoryTopics.length > 0 ? `topic:${categoryTopics[0]}` : '';
|
|
436
|
-
const phase3Query = `is:issue is:open no:assignee ${langQuery} ${topicQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
|
|
437
|
-
.replace(/ +/g, ' ')
|
|
438
|
-
.trim();
|
|
439
|
-
try {
|
|
440
|
-
const data = await cachedSearchIssues(this.octokit, {
|
|
441
|
-
q: phase3Query,
|
|
442
|
-
sort: 'updated',
|
|
443
|
-
order: 'desc',
|
|
444
|
-
per_page: remainingNeeded * 3,
|
|
445
|
-
});
|
|
446
|
-
info(MODULE, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
|
|
447
|
-
const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
|
|
448
|
-
const { candidates: starFiltered, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(this.vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], remainingNeeded, minStars, 'Phase 3');
|
|
449
|
-
allCandidates.push(...starFiltered);
|
|
450
|
-
if (allVetFailed) {
|
|
451
|
-
phase3Error = 'all vetting failed';
|
|
452
|
-
}
|
|
453
|
-
if (vetRateLimitHit) {
|
|
454
|
-
rateLimitHitDuringSearch = true;
|
|
455
|
-
}
|
|
456
|
-
info(MODULE, `Found ${starFiltered.length} candidates from maintained-repo search`);
|
|
457
|
-
}
|
|
458
|
-
catch (error) {
|
|
459
|
-
const errMsg = errorMessage(error);
|
|
460
|
-
phase3Error = errMsg;
|
|
461
|
-
if (isRateLimitError(error)) {
|
|
462
|
-
rateLimitHitDuringSearch = true;
|
|
463
|
-
}
|
|
464
|
-
warn(MODULE, `Error in maintained-repo search: ${errMsg}`);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
// Determine if phases were skipped due to budget constraints
|
|
468
|
-
const phasesSkippedForBudget = searchBudget < LOW_BUDGET_THRESHOLD;
|
|
469
|
-
let budgetNote = '';
|
|
470
|
-
if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
|
|
471
|
-
budgetNote = ` Most search phases were skipped due to critically low API quota (${searchBudget} remaining).`;
|
|
472
|
-
}
|
|
473
|
-
else if (phasesSkippedForBudget) {
|
|
474
|
-
budgetNote = ` Some search phases were skipped due to low API quota (${searchBudget} remaining).`;
|
|
475
|
-
}
|
|
476
|
-
if (allCandidates.length === 0) {
|
|
477
|
-
const phaseErrors = [
|
|
478
|
-
phase0Error ? `Phase 0 (merged-PR repos): ${phase0Error}` : null,
|
|
479
|
-
phase0_5Error ? `Phase 0.5 (preferred orgs): ${phase0_5Error}` : null,
|
|
480
|
-
phase1Error ? `Phase 1 (starred repos): ${phase1Error}` : null,
|
|
481
|
-
phase2Error ? `Phase 2 (general): ${phase2Error}` : null,
|
|
482
|
-
phase3Error ? `Phase 3 (maintained repos): ${phase3Error}` : null,
|
|
483
|
-
].filter(Boolean);
|
|
484
|
-
const details = phaseErrors.length > 0 ? ` ${phaseErrors.join('. ')}.` : '';
|
|
485
|
-
if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
|
|
486
|
-
this.rateLimitWarning =
|
|
487
|
-
`Search returned no results due to GitHub API rate limits.${details}${budgetNote} ` +
|
|
488
|
-
`Try again after the rate limit resets.`;
|
|
489
|
-
return [];
|
|
490
|
-
}
|
|
491
|
-
throw new ValidationError(`No issue candidates found across all search phases.${details} ` +
|
|
492
|
-
'Try adjusting your search criteria (languages, labels) or check your network connection.');
|
|
493
|
-
}
|
|
494
|
-
// Surface rate limit warning even with partial results (#100)
|
|
495
|
-
if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
|
|
496
|
-
this.rateLimitWarning =
|
|
497
|
-
`Search results may be incomplete: GitHub API rate limits were hit during search.${budgetNote} ` +
|
|
498
|
-
`Found ${allCandidates.length} candidate${allCandidates.length === 1 ? '' : 's'} but some search phases were limited. ` +
|
|
499
|
-
`Try again after the rate limit resets for complete results.`;
|
|
500
|
-
}
|
|
501
|
-
// Sort by priority first, then by recommendation, then by viability score
|
|
502
|
-
allCandidates.sort((a, b) => {
|
|
503
|
-
const priorityOrder = { merged_pr: 0, preferred_org: 1, starred: 2, normal: 3 };
|
|
504
|
-
const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
|
|
505
|
-
if (priorityDiff !== 0)
|
|
506
|
-
return priorityDiff;
|
|
507
|
-
const recommendationOrder = { approve: 0, needs_review: 1, skip: 2 };
|
|
508
|
-
const recDiff = recommendationOrder[a.recommendation] - recommendationOrder[b.recommendation];
|
|
509
|
-
if (recDiff !== 0)
|
|
510
|
-
return recDiff;
|
|
511
|
-
return b.viabilityScore - a.viabilityScore;
|
|
512
|
-
});
|
|
513
|
-
// Apply per-repo cap: max 2 issues from any single repo (#105)
|
|
514
|
-
const capped = applyPerRepoCap(allCandidates, 2);
|
|
515
|
-
info(MODULE, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${capped.length} candidates returned`);
|
|
516
|
-
return capped.slice(0, maxResults);
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Vet a specific issue for claimability and project health.
|
|
520
|
-
* @param issueUrl - Full GitHub issue URL
|
|
521
|
-
* @returns The vetted issue candidate with recommendation and scores
|
|
522
|
-
* @throws {ValidationError} If the URL is invalid or the issue cannot be fetched
|
|
523
|
-
*/
|
|
524
|
-
async vetIssue(issueUrl) {
|
|
525
|
-
return this.vetter.vetIssue(issueUrl);
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Save search results to ~/.oss-autopilot/found-issues.md.
|
|
529
|
-
* Results are sorted by viability score (highest first).
|
|
530
|
-
* @param candidates - Issue candidates to save
|
|
531
|
-
* @returns Absolute path to the written file
|
|
532
|
-
*/
|
|
533
|
-
saveSearchResults(candidates) {
|
|
534
|
-
// Sort by viability score descending
|
|
535
|
-
const sorted = [...candidates].sort((a, b) => b.viabilityScore - a.viabilityScore);
|
|
536
|
-
const outputDir = getDataDir();
|
|
537
|
-
const outputFile = path.join(outputDir, 'found-issues.md');
|
|
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
|
-
info(MODULE, `Saved ${sorted.length} issues to ${outputFile}`);
|
|
557
|
-
return outputFile;
|
|
558
|
-
}
|
|
559
|
-
/**
|
|
560
|
-
* Format issue candidate as a markdown display string.
|
|
561
|
-
* @param candidate - The issue candidate to format
|
|
562
|
-
* @returns Multi-line markdown string with vetting details
|
|
563
|
-
*/
|
|
564
|
-
formatCandidate(candidate) {
|
|
565
|
-
const { issue, vettingResult, projectHealth, recommendation, reasonsToApprove, reasonsToSkip } = candidate;
|
|
566
|
-
const statusIcon = recommendation === 'approve' ? '✅' : recommendation === 'skip' ? '❌' : '⚠️';
|
|
567
|
-
return `
|
|
568
|
-
## ${statusIcon} Issue Candidate: ${issue.repo}#${issue.number}
|
|
569
|
-
|
|
570
|
-
**Title:** ${issue.title}
|
|
571
|
-
**Labels:** ${issue.labels.join(', ')}
|
|
572
|
-
**Created:** ${new Date(issue.createdAt).toLocaleDateString()}
|
|
573
|
-
**URL:** ${issue.url}
|
|
574
|
-
|
|
575
|
-
### Vetting Results
|
|
576
|
-
${Object.entries(vettingResult.checks)
|
|
577
|
-
.map(([key, passed]) => `- ${passed ? '✓' : '✗'} ${key.replace(/([A-Z])/g, ' $1').toLowerCase()}`)
|
|
578
|
-
.join('\n')}
|
|
579
|
-
|
|
580
|
-
### Project Health
|
|
581
|
-
- Last commit: ${projectHealth.checkFailed ? 'unknown (API error)' : `${projectHealth.daysSinceLastCommit} days ago`}
|
|
582
|
-
- Open issues: ${projectHealth.openIssuesCount}
|
|
583
|
-
- CI status: ${projectHealth.ciStatus}
|
|
584
|
-
|
|
585
|
-
### Recommendation: **${recommendation.toUpperCase()}**
|
|
586
|
-
${reasonsToApprove.length > 0 ? `\n**Reasons to approve:**\n${reasonsToApprove.map((r) => `- ${r}`).join('\n')}` : ''}
|
|
587
|
-
${reasonsToSkip.length > 0 ? `\n**Reasons to skip:**\n${reasonsToSkip.map((r) => `- ${r}`).join('\n')}` : ''}
|
|
588
|
-
${vettingResult.notes.length > 0 ? `\n**Notes:**\n${vettingResult.notes.map((n) => `- ${n}`).join('\n')}` : ''}
|
|
589
|
-
`;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
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
|
-
* Uses the timeline API (REST) to detect cross-referenced PRs, avoiding
|
|
18
|
-
* the Search API's strict 30 req/min rate limit.
|
|
19
|
-
*/
|
|
20
|
-
export declare function checkNoExistingPR(octokit: Octokit, owner: string, repo: string, issueNumber: number): Promise<CheckResult>;
|
|
21
|
-
/**
|
|
22
|
-
* Check how many merged PRs the authenticated user has in a repo.
|
|
23
|
-
* Uses GitHub Search API. Returns 0 on error (non-fatal).
|
|
24
|
-
* Results are cached per-repo for 15 minutes to avoid redundant Search API
|
|
25
|
-
* calls when multiple issues from the same repo are vetted.
|
|
26
|
-
*/
|
|
27
|
-
export declare function checkUserMergedPRsInRepo(octokit: Octokit, owner: string, repo: string): Promise<number>;
|
|
28
|
-
/**
|
|
29
|
-
* Check whether an issue has been claimed by another contributor
|
|
30
|
-
* by scanning recent comments for claim phrases.
|
|
31
|
-
*/
|
|
32
|
-
export declare function checkNotClaimed(octokit: Octokit, owner: string, repo: string, issueNumber: number, commentCount: number): Promise<CheckResult>;
|
|
33
|
-
/**
|
|
34
|
-
* Analyze whether an issue body has clear, actionable requirements.
|
|
35
|
-
* Returns true when at least two "clarity indicators" are present:
|
|
36
|
-
* numbered/bulleted steps, code blocks, expected-behavior keywords, length > 200.
|
|
37
|
-
*/
|
|
38
|
-
export declare function analyzeRequirements(body: string): boolean;
|