@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.
@@ -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
  /**
@@ -7,6 +7,7 @@ export interface BootstrapResult {
7
7
  starredRepoCount: number;
8
8
  mergedPRCount: number;
9
9
  closedPRCount: number;
10
+ openPRCount: number;
10
11
  reposScoredCount: number;
11
12
  skippedDueToRateLimit: boolean;
12
13
  errors: string[];
@@ -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, searchInRepos, searchWithChunkedLabels, } from "./search-phases.js";
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, baseQualifiers, maxResults, filterIssues) {
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, allBatchesFailed, rateLimitHit } = await searchInRepos(octokit, vetter, repos, baseQualifiers, [], maxResults, "merged_pr", filterIssues);
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: allBatchesFailed ? "All merged-PR repo batches failed" : null,
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, baseQualifiers, labels, maxResults, filterIssues) {
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 to reduce Search API calls: starred repos already signal user
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 { candidates, allBatchesFailed, rateLimitHit } = await searchInRepos(octokit, vetter, repos.slice(0, 10), baseQualifiers, phase1Labels, maxResults, "starred", filterIssues);
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: allBatchesFailed ? "All starred repo batches failed" : null,
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: Merged-PR repos
331
- const phase0Repos = mergedPRRepos.slice(0, 10);
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, baseQualifiers, remaining, filterIssues);
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
- await sleep(INTER_PHASE_DELAY_MS);
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, baseQualifiers, labels, remaining, filterIssues);
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
- await sleep(INTER_PHASE_DELAY_MS);
368
- const remaining = maxResults - allCandidates.length;
369
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
370
- allCandidates.push(...result.candidates);
371
- phaseErrors["2"] = result.error;
372
- if (result.rateLimitHit)
373
- rateLimitHitDuringSearch = true;
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
- await sleep(INTER_PHASE_DELAY_MS);
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. */
@@ -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>;
@@ -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
  *