@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.
- package/dist/cli.bundle.cjs +63 -63
- package/dist/commands/comments.js +0 -1
- package/dist/commands/config.js +45 -5
- package/dist/commands/daily.js +190 -157
- package/dist/commands/dashboard-data.js +37 -30
- package/dist/commands/dashboard-server.js +0 -1
- package/dist/commands/dismiss.js +0 -6
- package/dist/commands/init.js +0 -1
- package/dist/commands/local-repos.js +1 -2
- package/dist/commands/move.js +12 -11
- package/dist/commands/setup.d.ts +2 -1
- package/dist/commands/setup.js +166 -130
- package/dist/commands/shelve.js +10 -10
- package/dist/commands/startup.js +30 -14
- package/dist/core/ci-analysis.d.ts +6 -0
- package/dist/core/ci-analysis.js +89 -12
- package/dist/core/daily-logic.js +24 -33
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +2 -1
- package/dist/core/issue-discovery.d.ts +7 -44
- package/dist/core/issue-discovery.js +83 -188
- package/dist/core/issue-eligibility.d.ts +35 -0
- package/dist/core/issue-eligibility.js +126 -0
- package/dist/core/issue-vetting.d.ts +6 -21
- package/dist/core/issue-vetting.js +15 -279
- package/dist/core/pr-monitor.d.ts +7 -12
- package/dist/core/pr-monitor.js +14 -80
- package/dist/core/repo-health.d.ts +24 -0
- package/dist/core/repo-health.js +193 -0
- package/dist/core/search-phases.d.ts +55 -0
- package/dist/core/search-phases.js +155 -0
- package/dist/core/state.d.ts +11 -0
- package/dist/core/state.js +63 -4
- package/dist/core/types.d.ts +8 -1
- package/dist/core/types.js +7 -0
- package/dist/formatters/json.d.ts +1 -1
- package/package.json +1 -1
package/dist/core/daily-logic.js
CHANGED
|
@@ -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
|
-
//
|
|
56
|
-
|
|
57
|
-
let
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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
|
|
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';
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
|
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
|
|
5
|
-
* - issue-filtering.ts
|
|
6
|
-
* - issue-scoring.ts
|
|
7
|
-
* - issue-vetting.ts
|
|
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
|
|
5
|
-
* - issue-filtering.ts
|
|
6
|
-
* - issue-scoring.ts
|
|
7
|
-
* - issue-vetting.ts
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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;
|