@oss-scout/core 0.3.0 → 0.5.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/dist/cli.bundle.cjs +45 -45
- package/dist/commands/config.js +6 -0
- package/dist/core/bootstrap.d.ts +1 -0
- package/dist/core/bootstrap.js +39 -1
- package/dist/core/gist-state-store.d.ts +1 -1
- package/dist/core/gist-state-store.js +2 -1
- package/dist/core/issue-discovery.js +92 -29
- package/dist/core/issue-vetting.d.ts +2 -0
- package/dist/core/schemas.d.ts +17 -0
- package/dist/core/schemas.js +9 -0
- package/dist/core/search-phases.d.ts +21 -0
- package/dist/core/search-phases.js +157 -11
- package/dist/core/types.d.ts +7 -0
- package/dist/index.d.ts +2 -2
- package/dist/scout.d.ts +7 -1
- package/dist/scout.js +26 -0
- package/package.json +14 -14
- package/dist/core/concurrency.d.ts +0 -6
- package/dist/core/concurrency.js +0 -25
package/dist/commands/config.js
CHANGED
|
@@ -13,6 +13,7 @@ const FIELD_CONFIGS = {
|
|
|
13
13
|
minStars: { type: "number" },
|
|
14
14
|
maxIssueAgeDays: { type: "number" },
|
|
15
15
|
minRepoScoreThreshold: { type: "number" },
|
|
16
|
+
interPhaseDelayMs: { type: "number" },
|
|
16
17
|
includeDocIssues: { type: "boolean" },
|
|
17
18
|
scope: { type: "enum-array", validValues: IssueScopeSchema.options },
|
|
18
19
|
projectCategories: {
|
|
@@ -25,6 +26,8 @@ const FIELD_CONFIGS = {
|
|
|
25
26
|
validValues: SearchStrategySchema.options,
|
|
26
27
|
},
|
|
27
28
|
githubUsername: { type: "string" },
|
|
29
|
+
broadPhaseDelayMs: { type: "number" },
|
|
30
|
+
skipBroadWhenSufficientResults: { type: "number" },
|
|
28
31
|
};
|
|
29
32
|
function parseBoolean(value) {
|
|
30
33
|
const lower = value.toLowerCase();
|
|
@@ -83,6 +86,7 @@ export function runConfigShow() {
|
|
|
83
86
|
console.log(` minStars: ${prefs.minStars}`);
|
|
84
87
|
console.log(` maxIssueAgeDays: ${prefs.maxIssueAgeDays}`);
|
|
85
88
|
console.log(` minRepoScoreThreshold: ${prefs.minRepoScoreThreshold}`);
|
|
89
|
+
console.log(` interPhaseDelayMs: ${prefs.interPhaseDelayMs}ms (${(prefs.interPhaseDelayMs / 1000).toFixed(0)}s)`);
|
|
86
90
|
console.log(` includeDocIssues: ${prefs.includeDocIssues}`);
|
|
87
91
|
console.log(` projectCategories: ${formatArray(prefs.projectCategories)}`);
|
|
88
92
|
console.log(` excludeRepos: ${formatArray(prefs.excludeRepos)}`);
|
|
@@ -90,6 +94,8 @@ export function runConfigShow() {
|
|
|
90
94
|
console.log(` aiPolicyBlocklist: ${formatArray(prefs.aiPolicyBlocklist)}`);
|
|
91
95
|
console.log(` defaultStrategy: ${prefs.defaultStrategy ? formatArray(prefs.defaultStrategy) : "(all)"}`);
|
|
92
96
|
console.log(` persistence: ${prefs.persistence}`);
|
|
97
|
+
console.log(` broadPhaseDelayMs: ${prefs.broadPhaseDelayMs}ms (${(prefs.broadPhaseDelayMs / 1000).toFixed(0)}s)`);
|
|
98
|
+
console.log(` skipBroadWhenSufficientResults: ${prefs.skipBroadWhenSufficientResults}`);
|
|
93
99
|
console.log();
|
|
94
100
|
}
|
|
95
101
|
/**
|
package/dist/core/bootstrap.d.ts
CHANGED
package/dist/core/bootstrap.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getOctokit, checkRateLimit } from "./github.js";
|
|
6
6
|
import { debug, warn } from "./logger.js";
|
|
7
|
-
import { ConfigurationError, errorMessage } from "./errors.js";
|
|
7
|
+
import { ConfigurationError, errorMessage, getHttpStatusCode, isRateLimitError, } from "./errors.js";
|
|
8
8
|
import { extractRepoFromUrl } from "./utils.js";
|
|
9
9
|
const MODULE = "bootstrap";
|
|
10
10
|
const STARRED_MAX_PAGES = 5;
|
|
@@ -23,6 +23,7 @@ export async function bootstrapScout(scout, token) {
|
|
|
23
23
|
starredRepoCount: 0,
|
|
24
24
|
mergedPRCount: 0,
|
|
25
25
|
closedPRCount: 0,
|
|
26
|
+
openPRCount: 0,
|
|
26
27
|
reposScoredCount: 0,
|
|
27
28
|
skippedDueToRateLimit: true,
|
|
28
29
|
errors: [],
|
|
@@ -80,6 +81,8 @@ export async function bootstrapScout(scout, token) {
|
|
|
80
81
|
debug(MODULE, `Imported ${mergedPRCount} merged PRs`);
|
|
81
82
|
}
|
|
82
83
|
catch (err) {
|
|
84
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
85
|
+
throw err;
|
|
83
86
|
warn(MODULE, `Failed to fetch merged PRs: ${errorMessage(err)}`);
|
|
84
87
|
errors.push("merged PR fetch failed");
|
|
85
88
|
}
|
|
@@ -110,15 +113,50 @@ export async function bootstrapScout(scout, token) {
|
|
|
110
113
|
debug(MODULE, `Imported ${closedPRCount} closed PRs`);
|
|
111
114
|
}
|
|
112
115
|
catch (err) {
|
|
116
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
117
|
+
throw err;
|
|
113
118
|
warn(MODULE, `Failed to fetch closed PRs: ${errorMessage(err)}`);
|
|
114
119
|
errors.push("closed PR fetch failed");
|
|
115
120
|
}
|
|
121
|
+
// 4. Fetch currently-open PRs via Search API
|
|
122
|
+
let openPRCount = 0;
|
|
123
|
+
try {
|
|
124
|
+
for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
|
|
125
|
+
const { data } = await octokit.search.issuesAndPullRequests({
|
|
126
|
+
q: `is:pr is:open author:${username}`,
|
|
127
|
+
per_page: PER_PAGE,
|
|
128
|
+
page,
|
|
129
|
+
});
|
|
130
|
+
for (const item of data.items) {
|
|
131
|
+
const repo = extractRepoFromUrl(item.html_url);
|
|
132
|
+
if (!repo)
|
|
133
|
+
continue;
|
|
134
|
+
scout.recordOpenPR({
|
|
135
|
+
url: item.html_url,
|
|
136
|
+
title: item.title,
|
|
137
|
+
openedAt: item.created_at ?? new Date().toISOString(),
|
|
138
|
+
repo,
|
|
139
|
+
});
|
|
140
|
+
openPRCount++;
|
|
141
|
+
}
|
|
142
|
+
if (data.items.length < PER_PAGE)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
debug(MODULE, `Imported ${openPRCount} open PRs`);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
149
|
+
throw err;
|
|
150
|
+
warn(MODULE, `Failed to fetch open PRs: ${errorMessage(err)}`);
|
|
151
|
+
errors.push("open PR fetch failed");
|
|
152
|
+
}
|
|
116
153
|
const state = scout.getState();
|
|
117
154
|
const reposScoredCount = Object.keys(state.repoScores).length;
|
|
118
155
|
return {
|
|
119
156
|
starredRepoCount: starredRepos.length,
|
|
120
157
|
mergedPRCount,
|
|
121
158
|
closedPRCount,
|
|
159
|
+
openPRCount,
|
|
122
160
|
reposScoredCount,
|
|
123
161
|
skippedDueToRateLimit: false,
|
|
124
162
|
errors,
|
|
@@ -88,7 +88,7 @@ export declare class GistStateStore {
|
|
|
88
88
|
/**
|
|
89
89
|
* Merge two ScoutState objects with conflict resolution:
|
|
90
90
|
* - repoScores: per-repo, keep the one with more total PR activity
|
|
91
|
-
* - mergedPRs/closedPRs: union by URL
|
|
91
|
+
* - mergedPRs/closedPRs/openPRs: union by URL
|
|
92
92
|
* - preferences: remote wins
|
|
93
93
|
* - starredRepos: keep the list with the fresher timestamp
|
|
94
94
|
* - savedResults: union by issueUrl, keep newer lastSeenAt
|
|
@@ -238,7 +238,7 @@ export class GistStateStore {
|
|
|
238
238
|
/**
|
|
239
239
|
* Merge two ScoutState objects with conflict resolution:
|
|
240
240
|
* - repoScores: per-repo, keep the one with more total PR activity
|
|
241
|
-
* - mergedPRs/closedPRs: union by URL
|
|
241
|
+
* - mergedPRs/closedPRs/openPRs: union by URL
|
|
242
242
|
* - preferences: remote wins
|
|
243
243
|
* - starredRepos: keep the list with the fresher timestamp
|
|
244
244
|
* - savedResults: union by issueUrl, keep newer lastSeenAt
|
|
@@ -252,6 +252,7 @@ export function mergeStates(local, remote) {
|
|
|
252
252
|
starredReposLastFetched: pickFresherTimestamp(local.starredReposLastFetched, remote.starredReposLastFetched),
|
|
253
253
|
mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
|
|
254
254
|
closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
|
|
255
|
+
openPRs: unionByUrl(local.openPRs ?? [], remote.openPRs ?? []),
|
|
255
256
|
savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
|
|
256
257
|
skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
|
|
257
258
|
lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
|
|
@@ -21,10 +21,8 @@ import { debug, info, warn } from "./logger.js";
|
|
|
21
21
|
import { isDocOnlyIssue, applyPerRepoCap, } from "./issue-filtering.js";
|
|
22
22
|
import { IssueVetter } from "./issue-vetting.js";
|
|
23
23
|
import { getTopicsForCategories } from "./category-mapping.js";
|
|
24
|
-
import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, filterVetAndScore,
|
|
24
|
+
import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, fetchIssuesFromMaintainedRepos, filterVetAndScore, fetchIssuesFromKnownRepos, searchWithChunkedLabels, } from "./search-phases.js";
|
|
25
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
26
|
/** If remaining search quota is below this, skip heavy phases (2, 3). */
|
|
29
27
|
const LOW_BUDGET_THRESHOLD = 20;
|
|
30
28
|
/** If remaining search quota is below this, only run Phase 0. */
|
|
@@ -60,27 +58,27 @@ function buildIssueFilter(config) {
|
|
|
60
58
|
};
|
|
61
59
|
}
|
|
62
60
|
/** Phase 0: Search repos where user has merged PRs (highest merge probability). */
|
|
63
|
-
async function runPhase0(octokit, vetter, repos,
|
|
61
|
+
async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
|
|
64
62
|
info(MODULE, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
|
|
65
|
-
const { candidates,
|
|
63
|
+
const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues);
|
|
66
64
|
info(MODULE, `Found ${candidates.length} candidates from merged-PR repos`);
|
|
67
65
|
return {
|
|
68
66
|
candidates,
|
|
69
|
-
error:
|
|
67
|
+
error: allReposFailed ? "All merged-PR repo fetches failed" : null,
|
|
70
68
|
rateLimitHit,
|
|
71
69
|
};
|
|
72
70
|
}
|
|
73
71
|
/** Phase 1: Search starred repos. */
|
|
74
|
-
async function runPhase1(octokit, vetter, repos,
|
|
72
|
+
async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssues) {
|
|
75
73
|
info(MODULE, `Phase 1: Searching issues in ${repos.length} starred repos...`);
|
|
76
|
-
// Cap labels
|
|
77
|
-
// interest, so fewer labels suffice.
|
|
74
|
+
// Cap labels: starred repos already signal user interest, so fewer labels suffice.
|
|
78
75
|
const phase1Labels = labels.slice(0, 3);
|
|
79
|
-
const
|
|
76
|
+
const reposToSearch = repos.slice(0, 10);
|
|
77
|
+
const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, reposToSearch, phase1Labels, maxResults, "starred", filterIssues);
|
|
80
78
|
info(MODULE, `Found ${candidates.length} candidates from starred repos`);
|
|
81
79
|
return {
|
|
82
80
|
candidates,
|
|
83
|
-
error:
|
|
81
|
+
error: allReposFailed ? "All starred repo fetches failed" : null,
|
|
84
82
|
rateLimitHit,
|
|
85
83
|
};
|
|
86
84
|
}
|
|
@@ -150,9 +148,36 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQual
|
|
|
150
148
|
rateLimitHit,
|
|
151
149
|
};
|
|
152
150
|
}
|
|
153
|
-
/** Phase 3: Actively maintained repos. */
|
|
154
|
-
async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
|
|
151
|
+
/** Phase 3: Actively maintained repos (REST-first, Search API fallback). */
|
|
152
|
+
async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues) {
|
|
155
153
|
info(MODULE, "Phase 3: Searching actively maintained repos...");
|
|
154
|
+
const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
|
|
155
|
+
// Step 1: Try REST API with starred repos first (no Search API quota used)
|
|
156
|
+
const eligibleStarred = starredRepos.filter((r) => !phase0RepoSet.has(r) && !seenRepos.has(r));
|
|
157
|
+
if (eligibleStarred.length > 0) {
|
|
158
|
+
info(MODULE, `Phase 3: Checking ${eligibleStarred.length} starred repos via REST API...`);
|
|
159
|
+
const restItems = await fetchIssuesFromMaintainedRepos(octokit, eligibleStarred.slice(0, 15), minStars, maxResults);
|
|
160
|
+
if (restItems.length > 0) {
|
|
161
|
+
try {
|
|
162
|
+
const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, restItems, filterIssues, [phase0RepoSet, seenRepos], maxResults, minStars, "Phase 3 (REST)");
|
|
163
|
+
if (candidates.length > 0) {
|
|
164
|
+
info(MODULE, `Found ${candidates.length} candidates from maintained-repo REST search`);
|
|
165
|
+
return {
|
|
166
|
+
candidates,
|
|
167
|
+
error: allVetFailed ? "all vetting failed" : null,
|
|
168
|
+
rateLimitHit: vetRateLimitHit,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (getHttpStatusCode(error) === 401)
|
|
174
|
+
throw error;
|
|
175
|
+
warn(MODULE, `Phase 3 REST vetting failed, falling back to Search API:`, errorMessage(error));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Step 2: Fall back to Search API if REST didn't yield results
|
|
180
|
+
info(MODULE, "Phase 3: Falling back to Search API...");
|
|
156
181
|
const thirtyDaysAgo = new Date();
|
|
157
182
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
158
183
|
const pushedSince = thirtyDaysAgo.toISOString().split("T")[0];
|
|
@@ -169,7 +194,6 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
|
|
|
169
194
|
per_page: maxResults * 3,
|
|
170
195
|
});
|
|
171
196
|
info(MODULE, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
|
|
172
|
-
const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
|
|
173
197
|
const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], maxResults, minStars, "Phase 3");
|
|
174
198
|
info(MODULE, `Found ${candidates.length} candidates from maintained-repo search`);
|
|
175
199
|
return {
|
|
@@ -259,6 +283,7 @@ export class IssueDiscovery {
|
|
|
259
283
|
(scopes ? buildEffectiveLabels(scopes, config.labels) : config.labels);
|
|
260
284
|
const maxResults = options.maxResults || 10;
|
|
261
285
|
const minStars = config.minStars ?? 50;
|
|
286
|
+
const interPhaseDelay = config.interPhaseDelayMs ?? 30000;
|
|
262
287
|
// Strategy selection
|
|
263
288
|
const ALL_STRATEGIES = CONCRETE_STRATEGIES;
|
|
264
289
|
const rawStrategies = options.strategies ??
|
|
@@ -301,6 +326,7 @@ export class IssueDiscovery {
|
|
|
301
326
|
}
|
|
302
327
|
// Derive search context
|
|
303
328
|
const mergedPRRepos = this.stateReader.getReposWithMergedPRs();
|
|
329
|
+
const openPRRepos = this.stateReader.getReposWithOpenPRs();
|
|
304
330
|
const starredRepos = this.getStarredRepos();
|
|
305
331
|
const starredRepoSet = new Set(starredRepos);
|
|
306
332
|
const lowScoringRepos = new Set(this.deriveLowScoringRepos(config.minRepoScoreThreshold));
|
|
@@ -327,13 +353,24 @@ export class IssueDiscovery {
|
|
|
327
353
|
now: new Date(),
|
|
328
354
|
includeDocIssues: config.includeDocIssues ?? true,
|
|
329
355
|
});
|
|
330
|
-
// Phase 0:
|
|
331
|
-
|
|
356
|
+
// Phase 0: Repos the user has engaged with — merged PRs first (strongest
|
|
357
|
+
// signal), then open PRs (active engagement even without a merge yet).
|
|
358
|
+
// Deduped and capped so REST cost stays bounded.
|
|
359
|
+
const seenPhase0 = new Set();
|
|
360
|
+
const phase0Repos = [];
|
|
361
|
+
for (const repo of [...mergedPRRepos, ...openPRRepos]) {
|
|
362
|
+
if (seenPhase0.has(repo))
|
|
363
|
+
continue;
|
|
364
|
+
seenPhase0.add(repo);
|
|
365
|
+
phase0Repos.push(repo);
|
|
366
|
+
if (phase0Repos.length >= 10)
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
332
369
|
const phase0RepoSet = new Set(phase0Repos);
|
|
333
370
|
if (phase0Repos.length > 0 && enabledStrategies.has("merged")) {
|
|
334
371
|
const remaining = maxResults - allCandidates.length;
|
|
335
372
|
if (remaining > 0) {
|
|
336
|
-
const result = await runPhase0(this.octokit, this.vetter, phase0Repos,
|
|
373
|
+
const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssues);
|
|
337
374
|
allCandidates.push(...result.candidates);
|
|
338
375
|
phaseErrors["0"] = result.error;
|
|
339
376
|
if (result.rateLimitHit)
|
|
@@ -346,12 +383,15 @@ export class IssueDiscovery {
|
|
|
346
383
|
starredRepos.length > 0 &&
|
|
347
384
|
searchBudget >= CRITICAL_BUDGET_THRESHOLD &&
|
|
348
385
|
enabledStrategies.has("starred")) {
|
|
349
|
-
|
|
386
|
+
if (interPhaseDelay > 0) {
|
|
387
|
+
info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
|
|
388
|
+
await sleep(interPhaseDelay);
|
|
389
|
+
}
|
|
350
390
|
const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
|
|
351
391
|
if (reposToSearch.length > 0) {
|
|
352
392
|
const remaining = maxResults - allCandidates.length;
|
|
353
393
|
if (remaining > 0) {
|
|
354
|
-
const result = await runPhase1(this.octokit, this.vetter, reposToSearch,
|
|
394
|
+
const result = await runPhase1(this.octokit, this.vetter, reposToSearch, labels, remaining, filterIssues);
|
|
355
395
|
allCandidates.push(...result.candidates);
|
|
356
396
|
phaseErrors["1"] = result.error;
|
|
357
397
|
if (result.rateLimitHit)
|
|
@@ -360,26 +400,49 @@ export class IssueDiscovery {
|
|
|
360
400
|
}
|
|
361
401
|
strategiesUsed.push("starred");
|
|
362
402
|
}
|
|
363
|
-
// Phase 2: General search
|
|
403
|
+
// Phase 2: General search (with rate limit mitigation)
|
|
404
|
+
const broadDelay = config.broadPhaseDelayMs ?? 90000;
|
|
405
|
+
const skipThreshold = config.skipBroadWhenSufficientResults ?? 15;
|
|
364
406
|
if (allCandidates.length < maxResults &&
|
|
365
407
|
searchBudget >= LOW_BUDGET_THRESHOLD &&
|
|
366
408
|
enabledStrategies.has("broad")) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
409
|
+
// Skip broad search if we already have enough candidates
|
|
410
|
+
if (skipThreshold > 0 && allCandidates.length >= skipThreshold) {
|
|
411
|
+
info(MODULE, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// Always apply baseline inter-phase delay
|
|
415
|
+
if (interPhaseDelay > 0) {
|
|
416
|
+
info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
|
|
417
|
+
await sleep(interPhaseDelay);
|
|
418
|
+
}
|
|
419
|
+
// Apply additional broad-phase cooldown, but skip if previous phases found nothing
|
|
420
|
+
if (allCandidates.length > 0 && broadDelay > 0) {
|
|
421
|
+
info(MODULE, `Waiting ${(broadDelay / 1000).toFixed(0)}s for rate limit cooldown before broad search...`);
|
|
422
|
+
await sleep(broadDelay);
|
|
423
|
+
}
|
|
424
|
+
else if (allCandidates.length === 0) {
|
|
425
|
+
info(MODULE, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
|
|
426
|
+
}
|
|
427
|
+
const remaining = maxResults - allCandidates.length;
|
|
428
|
+
const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
|
|
429
|
+
allCandidates.push(...result.candidates);
|
|
430
|
+
phaseErrors["2"] = result.error;
|
|
431
|
+
if (result.rateLimitHit)
|
|
432
|
+
rateLimitHitDuringSearch = true;
|
|
433
|
+
}
|
|
374
434
|
strategiesUsed.push("broad");
|
|
375
435
|
}
|
|
376
436
|
// Phase 3: Actively maintained repos
|
|
377
437
|
if (allCandidates.length < maxResults &&
|
|
378
438
|
searchBudget >= LOW_BUDGET_THRESHOLD &&
|
|
379
439
|
enabledStrategies.has("maintained")) {
|
|
380
|
-
|
|
440
|
+
if (interPhaseDelay > 0) {
|
|
441
|
+
info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
|
|
442
|
+
await sleep(interPhaseDelay);
|
|
443
|
+
}
|
|
381
444
|
const remaining = maxResults - allCandidates.length;
|
|
382
|
-
const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
|
|
445
|
+
const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues);
|
|
383
446
|
allCandidates.push(...result.candidates);
|
|
384
447
|
phaseErrors["3"] = result.error;
|
|
385
448
|
if (result.rateLimitHit)
|
|
@@ -15,6 +15,8 @@ import { type SearchPriority, type IssueCandidate, type ProjectCategory } from "
|
|
|
15
15
|
export interface ScoutStateReader {
|
|
16
16
|
/** Repos where the user has at least one merged PR. */
|
|
17
17
|
getReposWithMergedPRs(): string[];
|
|
18
|
+
/** Repos where the user has at least one open PR. */
|
|
19
|
+
getReposWithOpenPRs(): string[];
|
|
18
20
|
/** User's starred repos (from GitHub). */
|
|
19
21
|
getStarredRepos(): string[];
|
|
20
22
|
/** Preferred project categories from user preferences. */
|
package/dist/core/schemas.d.ts
CHANGED
|
@@ -64,6 +64,11 @@ export declare const StoredClosedPRSchema: z.ZodObject<{
|
|
|
64
64
|
title: z.ZodString;
|
|
65
65
|
closedAt: z.ZodString;
|
|
66
66
|
}, z.core.$strip>;
|
|
67
|
+
export declare const StoredOpenPRSchema: z.ZodObject<{
|
|
68
|
+
url: z.ZodString;
|
|
69
|
+
title: z.ZodString;
|
|
70
|
+
openedAt: z.ZodString;
|
|
71
|
+
}, z.core.$strip>;
|
|
67
72
|
export declare const ContributionGuidelinesSchema: z.ZodObject<{
|
|
68
73
|
branchNamingConvention: z.ZodOptional<z.ZodString>;
|
|
69
74
|
commitMessageFormat: z.ZodOptional<z.ZodString>;
|
|
@@ -203,6 +208,7 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
|
|
|
203
208
|
maxIssueAgeDays: z.ZodDefault<z.ZodNumber>;
|
|
204
209
|
includeDocIssues: z.ZodDefault<z.ZodBoolean>;
|
|
205
210
|
minRepoScoreThreshold: z.ZodDefault<z.ZodNumber>;
|
|
211
|
+
interPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
206
212
|
persistence: z.ZodDefault<z.ZodEnum<{
|
|
207
213
|
local: "local";
|
|
208
214
|
gist: "gist";
|
|
@@ -214,6 +220,8 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
|
|
|
214
220
|
broad: "broad";
|
|
215
221
|
maintained: "maintained";
|
|
216
222
|
}>>>;
|
|
223
|
+
broadPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
224
|
+
skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
|
|
217
225
|
}, z.core.$strip>;
|
|
218
226
|
export declare const ScoutStateSchema: z.ZodObject<{
|
|
219
227
|
version: z.ZodLiteral<1>;
|
|
@@ -241,6 +249,7 @@ export declare const ScoutStateSchema: z.ZodObject<{
|
|
|
241
249
|
maxIssueAgeDays: z.ZodDefault<z.ZodNumber>;
|
|
242
250
|
includeDocIssues: z.ZodDefault<z.ZodBoolean>;
|
|
243
251
|
minRepoScoreThreshold: z.ZodDefault<z.ZodNumber>;
|
|
252
|
+
interPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
244
253
|
persistence: z.ZodDefault<z.ZodEnum<{
|
|
245
254
|
local: "local";
|
|
246
255
|
gist: "gist";
|
|
@@ -252,6 +261,8 @@ export declare const ScoutStateSchema: z.ZodObject<{
|
|
|
252
261
|
broad: "broad";
|
|
253
262
|
maintained: "maintained";
|
|
254
263
|
}>>>;
|
|
264
|
+
broadPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
|
|
265
|
+
skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
|
|
255
266
|
}, z.core.$strip>>;
|
|
256
267
|
repoScores: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
257
268
|
repo: z.ZodString;
|
|
@@ -281,6 +292,11 @@ export declare const ScoutStateSchema: z.ZodObject<{
|
|
|
281
292
|
title: z.ZodString;
|
|
282
293
|
closedAt: z.ZodString;
|
|
283
294
|
}, z.core.$strip>>>;
|
|
295
|
+
openPRs: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
296
|
+
url: z.ZodString;
|
|
297
|
+
title: z.ZodString;
|
|
298
|
+
openedAt: z.ZodString;
|
|
299
|
+
}, z.core.$strip>>>;
|
|
284
300
|
savedResults: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
285
301
|
issueUrl: z.ZodString;
|
|
286
302
|
repo: z.ZodString;
|
|
@@ -316,6 +332,7 @@ export type RepoSignals = z.infer<typeof RepoSignalsSchema>;
|
|
|
316
332
|
export type RepoScore = z.infer<typeof RepoScoreSchema>;
|
|
317
333
|
export type StoredMergedPR = z.infer<typeof StoredMergedPRSchema>;
|
|
318
334
|
export type StoredClosedPR = z.infer<typeof StoredClosedPRSchema>;
|
|
335
|
+
export type StoredOpenPR = z.infer<typeof StoredOpenPRSchema>;
|
|
319
336
|
export type ContributionGuidelines = z.infer<typeof ContributionGuidelinesSchema>;
|
|
320
337
|
export type IssueVettingResult = z.infer<typeof IssueVettingResultSchema>;
|
|
321
338
|
export type TrackedIssue = z.infer<typeof TrackedIssueSchema>;
|
package/dist/core/schemas.js
CHANGED
|
@@ -67,6 +67,11 @@ export const StoredClosedPRSchema = z.object({
|
|
|
67
67
|
title: z.string(),
|
|
68
68
|
closedAt: z.string(),
|
|
69
69
|
});
|
|
70
|
+
export const StoredOpenPRSchema = z.object({
|
|
71
|
+
url: z.string(),
|
|
72
|
+
title: z.string(),
|
|
73
|
+
openedAt: z.string(),
|
|
74
|
+
});
|
|
70
75
|
// ── Contribution schemas ────────────────────────────────────────────
|
|
71
76
|
export const ContributionGuidelinesSchema = z.object({
|
|
72
77
|
branchNamingConvention: z.string().optional(),
|
|
@@ -146,8 +151,11 @@ export const ScoutPreferencesSchema = z.object({
|
|
|
146
151
|
maxIssueAgeDays: z.number().default(90),
|
|
147
152
|
includeDocIssues: z.boolean().default(true),
|
|
148
153
|
minRepoScoreThreshold: z.number().default(4),
|
|
154
|
+
interPhaseDelayMs: z.number().min(0).max(120000).default(30000),
|
|
149
155
|
persistence: PersistenceModeSchema.default("local"),
|
|
150
156
|
defaultStrategy: z.array(SearchStrategySchema).optional(),
|
|
157
|
+
broadPhaseDelayMs: z.number().min(0).max(300000).default(90000),
|
|
158
|
+
skipBroadWhenSufficientResults: z.number().int().min(0).max(100).default(15),
|
|
151
159
|
});
|
|
152
160
|
// ── Root state schema ───────────────────────────────────────────────
|
|
153
161
|
export const ScoutStateSchema = z.object({
|
|
@@ -158,6 +166,7 @@ export const ScoutStateSchema = z.object({
|
|
|
158
166
|
starredReposLastFetched: z.string().optional(),
|
|
159
167
|
mergedPRs: z.array(StoredMergedPRSchema).default([]),
|
|
160
168
|
closedPRs: z.array(StoredClosedPRSchema).default([]),
|
|
169
|
+
openPRs: z.array(StoredOpenPRSchema).default([]),
|
|
161
170
|
savedResults: z.array(SavedCandidateSchema).default([]),
|
|
162
171
|
skippedIssues: z.array(SkippedIssueSchema).default([]),
|
|
163
172
|
lastSearchAt: z.string().optional(),
|
|
@@ -26,6 +26,27 @@ export declare function cachedSearchIssues(octokit: Octokit, params: {
|
|
|
26
26
|
total_count: number;
|
|
27
27
|
items: GitHubSearchItem[];
|
|
28
28
|
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Fetch issues from maintained repos using REST API (no Search API quota).
|
|
31
|
+
*
|
|
32
|
+
* Checks each repo for recent push activity and star threshold,
|
|
33
|
+
* then fetches open issues via `GET /repos/{owner}/{repo}/issues`.
|
|
34
|
+
* Falls back to the caller to use Search API if this doesn't yield enough.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fetchIssuesFromMaintainedRepos(octokit: Octokit, repos: string[], minStars: number, maxResults: number): Promise<GitHubSearchItem[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch open issues from known repos using REST API (no Search API quota).
|
|
39
|
+
* Used by Phase 0 (merged-PR repos) and Phase 1 (starred repos).
|
|
40
|
+
*
|
|
41
|
+
* Instead of the Search API (`octokit.search.issuesAndPullRequests`), this
|
|
42
|
+
* calls `GET /repos/{owner}/{repo}/issues` which counts against the much
|
|
43
|
+
* larger Core API rate limit and avoids consuming the scarce Search quota.
|
|
44
|
+
*/
|
|
45
|
+
export declare function fetchIssuesFromKnownRepos(octokit: Octokit, vetter: IssueVetter, repos: string[], labels: string[], maxResults: number, priority: SearchPriority, filterFn: (items: GitHubSearchItem[]) => GitHubSearchItem[]): Promise<{
|
|
46
|
+
candidates: IssueCandidate[];
|
|
47
|
+
allReposFailed: boolean;
|
|
48
|
+
rateLimitHit: boolean;
|
|
49
|
+
}>;
|
|
29
50
|
/**
|
|
30
51
|
* Search across chunked labels with deduplication.
|
|
31
52
|
*
|