@oss-scout/core 0.2.1 → 0.4.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.
@@ -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. */
@@ -47,6 +45,8 @@ function buildIssueFilter(config) {
47
45
  return false;
48
46
  if (config.lowScoringRepos.has(repoFullName))
49
47
  return false;
48
+ if (config.skippedUrls.has(item.html_url))
49
+ return false;
50
50
  const updatedAt = new Date(item.updated_at);
51
51
  const ageDays = daysBetween(updatedAt, config.now);
52
52
  if (ageDays > config.maxAgeDays)
@@ -58,63 +58,27 @@ function buildIssueFilter(config) {
58
58
  };
59
59
  }
60
60
  /** Phase 0: Search repos where user has merged PRs (highest merge probability). */
61
- async function runPhase0(octokit, vetter, repos, baseQualifiers, maxResults, filterIssues) {
61
+ async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
62
62
  info(MODULE, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
63
- 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);
64
64
  info(MODULE, `Found ${candidates.length} candidates from merged-PR repos`);
65
65
  return {
66
66
  candidates,
67
- error: allBatchesFailed ? "All merged-PR repo batches failed" : null,
67
+ error: allReposFailed ? "All merged-PR repo fetches failed" : null,
68
68
  rateLimitHit,
69
69
  };
70
70
  }
71
- /** Phase 0.5: Search preferred organizations. */
72
- async function runPhase05(octokit, vetter, orgsToSearch, baseQualifiers, labels, maxResults, phase0RepoSet, filterIssues) {
73
- info(MODULE, `Phase 0.5: Searching issues in ${orgsToSearch.length} preferred org(s)...`);
74
- const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(" OR ");
75
- const orgOps = orgsToSearch.length - 1;
76
- try {
77
- const allItems = await searchWithChunkedLabels(octokit, labels, orgOps, (labelQ) => `${baseQualifiers} ${labelQ} (${orgRepoFilter})`
78
- .replace(/ +/g, " ")
79
- .trim(), maxResults * 3);
80
- if (allItems.length === 0) {
81
- return { candidates: [], error: null, rateLimitHit: false };
82
- }
83
- const filtered = filterIssues(allItems).filter((item) => {
84
- const repoFullName = extractRepoFromUrl(item.repository_url);
85
- if (!repoFullName)
86
- return false;
87
- return !phase0RepoSet.has(repoFullName);
88
- });
89
- const { candidates, allFailed: allVetFailed, rateLimitHit, } = await vetter.vetIssuesParallel(filtered.slice(0, maxResults * 2).map((i) => i.html_url), maxResults, "preferred_org");
90
- info(MODULE, `Found ${candidates.length} candidates from preferred orgs`);
91
- return {
92
- candidates,
93
- error: allVetFailed ? "All preferred org issue vetting failed" : null,
94
- rateLimitHit,
95
- };
96
- }
97
- catch (error) {
98
- const errMsg = errorMessage(error);
99
- warn(MODULE, `Error searching preferred orgs: ${errMsg}`);
100
- return {
101
- candidates: [],
102
- error: errMsg,
103
- rateLimitHit: isRateLimitError(error),
104
- };
105
- }
106
- }
107
71
  /** Phase 1: Search starred repos. */
108
- async function runPhase1(octokit, vetter, repos, baseQualifiers, labels, maxResults, filterIssues) {
72
+ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssues) {
109
73
  info(MODULE, `Phase 1: Searching issues in ${repos.length} starred repos...`);
110
- // Cap labels to reduce Search API calls: starred repos already signal user
111
- // interest, so fewer labels suffice.
74
+ // Cap labels: starred repos already signal user interest, so fewer labels suffice.
112
75
  const phase1Labels = labels.slice(0, 3);
113
- 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);
114
78
  info(MODULE, `Found ${candidates.length} candidates from starred repos`);
115
79
  return {
116
80
  candidates,
117
- error: allBatchesFailed ? "All starred repo batches failed" : null,
81
+ error: allReposFailed ? "All starred repo fetches failed" : null,
118
82
  rateLimitHit,
119
83
  };
120
84
  }
@@ -184,9 +148,36 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQual
184
148
  rateLimitHit,
185
149
  };
186
150
  }
187
- /** Phase 3: Actively maintained repos. */
188
- 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) {
189
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...");
190
181
  const thirtyDaysAgo = new Date();
191
182
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
192
183
  const pushedSince = thirtyDaysAgo.toISOString().split("T")[0];
@@ -203,7 +194,6 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
203
194
  per_page: maxResults * 3,
204
195
  });
205
196
  info(MODULE, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
206
- const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
207
197
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], maxResults, minStars, "Phase 3");
208
198
  info(MODULE, `Found ${candidates.length} candidates from maintained-repo search`);
209
199
  return {
@@ -228,7 +218,6 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
228
218
  *
229
219
  * Search phases (in priority order):
230
220
  * 0. Repos where user has merged PRs (highest merge probability)
231
- * 0.5. Preferred organizations
232
221
  * 1. Starred repos
233
222
  * 2. General label-filtered search
234
223
  * 3. Actively maintained repos
@@ -264,8 +253,8 @@ export class IssueDiscovery {
264
253
  }
265
254
  /**
266
255
  * Search for issues matching our criteria.
267
- * Searches in priority order: merged-PR repos first (no label filter), then preferred
268
- * organizations, then starred repos, then general search, then actively maintained repos.
256
+ * Searches in priority order: merged-PR repos first (no label filter), then starred
257
+ * repos, then general search, then actively maintained repos.
269
258
  * Filters out issues from low-scoring and excluded repos.
270
259
  *
271
260
  * @param options - Search configuration
@@ -294,6 +283,7 @@ export class IssueDiscovery {
294
283
  (scopes ? buildEffectiveLabels(scopes, config.labels) : config.labels);
295
284
  const maxResults = options.maxResults || 10;
296
285
  const minStars = config.minStars ?? 50;
286
+ const interPhaseDelay = config.interPhaseDelayMs ?? 30000;
297
287
  // Strategy selection
298
288
  const ALL_STRATEGIES = CONCRETE_STRATEGIES;
299
289
  const rawStrategies = options.strategies ??
@@ -329,6 +319,11 @@ export class IssueDiscovery {
329
319
  tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 60000).toISOString());
330
320
  warn(MODULE, "Could not check rate limit — using conservative budget, skipping heavy phases:", errorMessage(error));
331
321
  }
322
+ if (searchBudget <= 0) {
323
+ this.rateLimitWarning =
324
+ "GitHub search API quota exhausted. Try again after the rate limit resets.";
325
+ return { candidates: [], strategiesUsed: [] };
326
+ }
332
327
  // Derive search context
333
328
  const mergedPRRepos = this.stateReader.getReposWithMergedPRs();
334
329
  const starredRepos = this.getStarredRepos();
@@ -352,6 +347,7 @@ export class IssueDiscovery {
352
347
  excludeOrgs: new Set((config.excludeOrgs ?? []).map((o) => o.toLowerCase())),
353
348
  aiBlocklisted,
354
349
  lowScoringRepos,
350
+ skippedUrls: options.skippedUrls ?? new Set(),
355
351
  maxAgeDays: config.maxIssueAgeDays || 90,
356
352
  now: new Date(),
357
353
  includeDocIssues: config.includeDocIssues ?? true,
@@ -362,7 +358,7 @@ export class IssueDiscovery {
362
358
  if (phase0Repos.length > 0 && enabledStrategies.has("merged")) {
363
359
  const remaining = maxResults - allCandidates.length;
364
360
  if (remaining > 0) {
365
- const result = await runPhase0(this.octokit, this.vetter, phase0Repos, baseQualifiers, remaining, filterIssues);
361
+ const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssues);
366
362
  allCandidates.push(...result.candidates);
367
363
  phaseErrors["0"] = result.error;
368
364
  if (result.rateLimitHit)
@@ -370,39 +366,20 @@ export class IssueDiscovery {
370
366
  }
371
367
  strategiesUsed.push("merged");
372
368
  }
373
- // Phase 0.5: Preferred organizations
374
- const preferredOrgs = config.preferredOrgs ?? [];
375
- if (allCandidates.length < maxResults &&
376
- preferredOrgs.length > 0 &&
377
- searchBudget >= CRITICAL_BUDGET_THRESHOLD &&
378
- enabledStrategies.has("orgs")) {
379
- if (phase0Repos.length > 0)
380
- await sleep(INTER_PHASE_DELAY_MS);
381
- const phase0Orgs = new Set(phase0Repos.map((r) => r.split("/")[0]?.toLowerCase()));
382
- const orgsToSearch = preferredOrgs
383
- .filter((org) => !phase0Orgs.has(org.toLowerCase()))
384
- .slice(0, 5);
385
- if (orgsToSearch.length > 0) {
386
- const remaining = maxResults - allCandidates.length;
387
- const result = await runPhase05(this.octokit, this.vetter, orgsToSearch, baseQualifiers, labels, remaining, phase0RepoSet, filterIssues);
388
- allCandidates.push(...result.candidates);
389
- phaseErrors["0.5"] = result.error;
390
- if (result.rateLimitHit)
391
- rateLimitHitDuringSearch = true;
392
- }
393
- strategiesUsed.push("orgs");
394
- }
395
369
  // Phase 1: Starred repos
396
370
  if (allCandidates.length < maxResults &&
397
371
  starredRepos.length > 0 &&
398
372
  searchBudget >= CRITICAL_BUDGET_THRESHOLD &&
399
373
  enabledStrategies.has("starred")) {
400
- await sleep(INTER_PHASE_DELAY_MS);
374
+ if (interPhaseDelay > 0) {
375
+ info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
376
+ await sleep(interPhaseDelay);
377
+ }
401
378
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
402
379
  if (reposToSearch.length > 0) {
403
380
  const remaining = maxResults - allCandidates.length;
404
381
  if (remaining > 0) {
405
- const result = await runPhase1(this.octokit, this.vetter, reposToSearch, baseQualifiers, labels, remaining, filterIssues);
382
+ const result = await runPhase1(this.octokit, this.vetter, reposToSearch, labels, remaining, filterIssues);
406
383
  allCandidates.push(...result.candidates);
407
384
  phaseErrors["1"] = result.error;
408
385
  if (result.rateLimitHit)
@@ -411,26 +388,49 @@ export class IssueDiscovery {
411
388
  }
412
389
  strategiesUsed.push("starred");
413
390
  }
414
- // Phase 2: General search
391
+ // Phase 2: General search (with rate limit mitigation)
392
+ const broadDelay = config.broadPhaseDelayMs ?? 90000;
393
+ const skipThreshold = config.skipBroadWhenSufficientResults ?? 15;
415
394
  if (allCandidates.length < maxResults &&
416
395
  searchBudget >= LOW_BUDGET_THRESHOLD &&
417
396
  enabledStrategies.has("broad")) {
418
- await sleep(INTER_PHASE_DELAY_MS);
419
- const remaining = maxResults - allCandidates.length;
420
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
421
- allCandidates.push(...result.candidates);
422
- phaseErrors["2"] = result.error;
423
- if (result.rateLimitHit)
424
- rateLimitHitDuringSearch = true;
397
+ // Skip broad search if we already have enough candidates
398
+ if (skipThreshold > 0 && allCandidates.length >= skipThreshold) {
399
+ info(MODULE, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
400
+ }
401
+ else {
402
+ // Always apply baseline inter-phase delay
403
+ if (interPhaseDelay > 0) {
404
+ info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
405
+ await sleep(interPhaseDelay);
406
+ }
407
+ // Apply additional broad-phase cooldown, but skip if previous phases found nothing
408
+ if (allCandidates.length > 0 && broadDelay > 0) {
409
+ info(MODULE, `Waiting ${(broadDelay / 1000).toFixed(0)}s for rate limit cooldown before broad search...`);
410
+ await sleep(broadDelay);
411
+ }
412
+ else if (allCandidates.length === 0) {
413
+ info(MODULE, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
414
+ }
415
+ const remaining = maxResults - allCandidates.length;
416
+ const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
417
+ allCandidates.push(...result.candidates);
418
+ phaseErrors["2"] = result.error;
419
+ if (result.rateLimitHit)
420
+ rateLimitHitDuringSearch = true;
421
+ }
425
422
  strategiesUsed.push("broad");
426
423
  }
427
424
  // Phase 3: Actively maintained repos
428
425
  if (allCandidates.length < maxResults &&
429
426
  searchBudget >= LOW_BUDGET_THRESHOLD &&
430
427
  enabledStrategies.has("maintained")) {
431
- await sleep(INTER_PHASE_DELAY_MS);
428
+ if (interPhaseDelay > 0) {
429
+ info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
430
+ await sleep(interPhaseDelay);
431
+ }
432
432
  const remaining = maxResults - allCandidates.length;
433
- const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
433
+ const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues);
434
434
  allCandidates.push(...result.candidates);
435
435
  phaseErrors["3"] = result.error;
436
436
  if (result.rateLimitHit)
@@ -451,9 +451,6 @@ export class IssueDiscovery {
451
451
  phaseErrors["0"]
452
452
  ? `Phase 0 (merged-PR repos): ${phaseErrors["0"]}`
453
453
  : null,
454
- phaseErrors["0.5"]
455
- ? `Phase 0.5 (preferred orgs): ${phaseErrors["0.5"]}`
456
- : null,
457
454
  phaseErrors["1"]
458
455
  ? `Phase 1 (starred repos): ${phaseErrors["1"]}`
459
456
  : null,
@@ -482,9 +479,8 @@ export class IssueDiscovery {
482
479
  allCandidates.sort((a, b) => {
483
480
  const priorityOrder = {
484
481
  merged_pr: 0,
485
- preferred_org: 1,
486
- starred: 2,
487
- normal: 3,
482
+ starred: 1,
483
+ normal: 2,
488
484
  };
489
485
  const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
490
486
  if (priorityDiff !== 0)
@@ -17,8 +17,6 @@ export interface ScoutStateReader {
17
17
  getReposWithMergedPRs(): string[];
18
18
  /** User's starred repos (from GitHub). */
19
19
  getStarredRepos(): string[];
20
- /** Preferred GitHub orgs from user preferences. */
21
- getPreferredOrgs(): string[];
22
20
  /** Preferred project categories from user preferences. */
23
21
  getProjectCategories(): ProjectCategory[];
24
22
  /** Numeric quality score for a repo, or null if not evaluated. */
@@ -205,14 +205,10 @@ export class IssueVetter {
205
205
  matchesPreferredCategory: matchesCategory,
206
206
  });
207
207
  const starredRepos = this.stateReader.getStarredRepos();
208
- const preferredOrgs = this.stateReader.getPreferredOrgs();
209
208
  let searchPriority = "normal";
210
209
  if (effectiveMergedCount > 0) {
211
210
  searchPriority = "merged_pr";
212
211
  }
213
- else if (preferredOrgs.some((o) => o.toLowerCase() === orgName?.toLowerCase())) {
214
- searchPriority = "preferred_org";
215
- }
216
212
  else if (starredRepos.includes(repoFullName)) {
217
213
  searchPriority = "starred";
218
214
  }
@@ -39,8 +39,8 @@ export function loadLocalState() {
39
39
  fs.copyFileSync(statePath, backupPath);
40
40
  warn(MODULE, `Corrupt state backed up to ${backupPath}`);
41
41
  }
42
- catch {
43
- /* best effort backup */
42
+ catch (backupErr) {
43
+ warn(MODULE, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
44
44
  }
45
45
  return ScoutStateSchema.parse({ version: 1 });
46
46
  }
@@ -27,13 +27,12 @@ export declare const IssueScopeSchema: z.ZodEnum<{
27
27
  export declare const SearchStrategySchema: z.ZodEnum<{
28
28
  all: "all";
29
29
  merged: "merged";
30
- orgs: "orgs";
31
30
  starred: "starred";
32
31
  broad: "broad";
33
32
  maintained: "maintained";
34
33
  }>;
35
34
  /** All concrete strategies (excludes 'all' meta-strategy). */
36
- export declare const CONCRETE_STRATEGIES: readonly ["merged", "orgs", "starred", "broad", "maintained"];
35
+ export declare const CONCRETE_STRATEGIES: readonly ["merged", "starred", "broad", "maintained"];
37
36
  export declare const RepoSignalsSchema: z.ZodObject<{
38
37
  hasActiveMaintainers: z.ZodBoolean;
39
38
  isResponsive: z.ZodBoolean;
@@ -152,6 +151,13 @@ export declare const TrackedIssueSchema: z.ZodObject<{
152
151
  notes: z.ZodArray<z.ZodString>;
153
152
  }, z.core.$strip>>;
154
153
  }, z.core.$strip>;
154
+ export declare const SkippedIssueSchema: z.ZodObject<{
155
+ url: z.ZodString;
156
+ repo: z.ZodString;
157
+ number: z.ZodNumber;
158
+ title: z.ZodString;
159
+ skippedAt: z.ZodString;
160
+ }, z.core.$strip>;
155
161
  export declare const SavedCandidateSchema: z.ZodObject<{
156
162
  issueUrl: z.ZodString;
157
163
  repo: z.ZodString;
@@ -185,7 +191,6 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
185
191
  excludeRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
186
192
  excludeOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
187
193
  aiPolicyBlocklist: z.ZodDefault<z.ZodArray<z.ZodString>>;
188
- preferredOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
189
194
  projectCategories: z.ZodDefault<z.ZodArray<z.ZodEnum<{
190
195
  nonprofit: "nonprofit";
191
196
  devtools: "devtools";
@@ -198,6 +203,7 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
198
203
  maxIssueAgeDays: z.ZodDefault<z.ZodNumber>;
199
204
  includeDocIssues: z.ZodDefault<z.ZodBoolean>;
200
205
  minRepoScoreThreshold: z.ZodDefault<z.ZodNumber>;
206
+ interPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
201
207
  persistence: z.ZodDefault<z.ZodEnum<{
202
208
  local: "local";
203
209
  gist: "gist";
@@ -205,11 +211,12 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
205
211
  defaultStrategy: z.ZodOptional<z.ZodArray<z.ZodEnum<{
206
212
  all: "all";
207
213
  merged: "merged";
208
- orgs: "orgs";
209
214
  starred: "starred";
210
215
  broad: "broad";
211
216
  maintained: "maintained";
212
217
  }>>>;
218
+ broadPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
219
+ skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
213
220
  }, z.core.$strip>;
214
221
  export declare const ScoutStateSchema: z.ZodObject<{
215
222
  version: z.ZodLiteral<1>;
@@ -225,7 +232,6 @@ export declare const ScoutStateSchema: z.ZodObject<{
225
232
  excludeRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
226
233
  excludeOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
227
234
  aiPolicyBlocklist: z.ZodDefault<z.ZodArray<z.ZodString>>;
228
- preferredOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
229
235
  projectCategories: z.ZodDefault<z.ZodArray<z.ZodEnum<{
230
236
  nonprofit: "nonprofit";
231
237
  devtools: "devtools";
@@ -238,6 +244,7 @@ export declare const ScoutStateSchema: z.ZodObject<{
238
244
  maxIssueAgeDays: z.ZodDefault<z.ZodNumber>;
239
245
  includeDocIssues: z.ZodDefault<z.ZodBoolean>;
240
246
  minRepoScoreThreshold: z.ZodDefault<z.ZodNumber>;
247
+ interPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
241
248
  persistence: z.ZodDefault<z.ZodEnum<{
242
249
  local: "local";
243
250
  gist: "gist";
@@ -245,11 +252,12 @@ export declare const ScoutStateSchema: z.ZodObject<{
245
252
  defaultStrategy: z.ZodOptional<z.ZodArray<z.ZodEnum<{
246
253
  all: "all";
247
254
  merged: "merged";
248
- orgs: "orgs";
249
255
  starred: "starred";
250
256
  broad: "broad";
251
257
  maintained: "maintained";
252
258
  }>>>;
259
+ broadPhaseDelayMs: z.ZodDefault<z.ZodNumber>;
260
+ skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
253
261
  }, z.core.$strip>>;
254
262
  repoScores: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
255
263
  repo: z.ZodString;
@@ -296,11 +304,17 @@ export declare const ScoutStateSchema: z.ZodObject<{
296
304
  lastSeenAt: z.ZodString;
297
305
  lastScore: z.ZodNumber;
298
306
  }, z.core.$strip>>>;
307
+ skippedIssues: z.ZodDefault<z.ZodArray<z.ZodObject<{
308
+ url: z.ZodString;
309
+ repo: z.ZodString;
310
+ number: z.ZodNumber;
311
+ title: z.ZodString;
312
+ skippedAt: z.ZodString;
313
+ }, z.core.$strip>>>;
299
314
  lastSearchAt: z.ZodOptional<z.ZodString>;
300
315
  lastRunAt: z.ZodDefault<z.ZodString>;
301
316
  gistId: z.ZodOptional<z.ZodString>;
302
317
  }, z.core.$strip>;
303
- export type IssueStatus = z.infer<typeof IssueStatusSchema>;
304
318
  export type ProjectCategory = z.infer<typeof ProjectCategorySchema>;
305
319
  export type IssueScope = z.infer<typeof IssueScopeSchema>;
306
320
  export type SearchStrategy = z.infer<typeof SearchStrategySchema>;
@@ -311,7 +325,7 @@ export type StoredClosedPR = z.infer<typeof StoredClosedPRSchema>;
311
325
  export type ContributionGuidelines = z.infer<typeof ContributionGuidelinesSchema>;
312
326
  export type IssueVettingResult = z.infer<typeof IssueVettingResultSchema>;
313
327
  export type TrackedIssue = z.infer<typeof TrackedIssueSchema>;
314
- export type PersistenceMode = z.infer<typeof PersistenceModeSchema>;
315
328
  export type ScoutPreferences = z.infer<typeof ScoutPreferencesSchema>;
316
329
  export type SavedCandidate = z.infer<typeof SavedCandidateSchema>;
330
+ export type SkippedIssue = z.infer<typeof SkippedIssueSchema>;
317
331
  export type ScoutState = z.infer<typeof ScoutStateSchema>;
@@ -27,7 +27,6 @@ export const IssueScopeSchema = z.enum([
27
27
  ]);
28
28
  export const SearchStrategySchema = z.enum([
29
29
  "merged",
30
- "orgs",
31
30
  "starred",
32
31
  "broad",
33
32
  "maintained",
@@ -36,7 +35,6 @@ export const SearchStrategySchema = z.enum([
36
35
  /** All concrete strategies (excludes 'all' meta-strategy). */
37
36
  export const CONCRETE_STRATEGIES = [
38
37
  "merged",
39
- "orgs",
40
38
  "starred",
41
39
  "broad",
42
40
  "maintained",
@@ -111,6 +109,14 @@ export const TrackedIssueSchema = z.object({
111
109
  vetted: z.boolean(),
112
110
  vettingResult: IssueVettingResultSchema.optional(),
113
111
  });
112
+ // ── Skipped issue schema ──────────────────────────────────────────
113
+ export const SkippedIssueSchema = z.object({
114
+ url: z.string(),
115
+ repo: z.string(),
116
+ number: z.number(),
117
+ title: z.string(),
118
+ skippedAt: z.string(),
119
+ });
114
120
  // ── Saved candidate schema ─────────────────────────────────────────
115
121
  export const SavedCandidateSchema = z.object({
116
122
  issueUrl: z.string(),
@@ -129,20 +135,22 @@ export const SavedCandidateSchema = z.object({
129
135
  export const PersistenceModeSchema = z.enum(["local", "gist"]);
130
136
  export const ScoutPreferencesSchema = z.object({
131
137
  githubUsername: z.string().default(""),
132
- languages: z.array(z.string()).default(["typescript", "javascript"]),
138
+ languages: z.array(z.string()).default(["any"]),
133
139
  labels: z.array(z.string()).default(["good first issue", "help wanted"]),
134
140
  scope: z.array(IssueScopeSchema).optional(),
135
141
  excludeRepos: z.array(z.string()).default([]),
136
142
  excludeOrgs: z.array(z.string()).default([]),
137
143
  aiPolicyBlocklist: z.array(z.string()).default(["matplotlib/matplotlib"]),
138
- preferredOrgs: z.array(z.string()).default([]),
139
144
  projectCategories: z.array(ProjectCategorySchema).default([]),
140
145
  minStars: z.number().default(50),
141
146
  maxIssueAgeDays: z.number().default(90),
142
147
  includeDocIssues: z.boolean().default(true),
143
148
  minRepoScoreThreshold: z.number().default(4),
149
+ interPhaseDelayMs: z.number().min(0).max(120000).default(30000),
144
150
  persistence: PersistenceModeSchema.default("local"),
145
151
  defaultStrategy: z.array(SearchStrategySchema).optional(),
152
+ broadPhaseDelayMs: z.number().min(0).max(300000).default(90000),
153
+ skipBroadWhenSufficientResults: z.number().int().min(0).max(100).default(15),
146
154
  });
147
155
  // ── Root state schema ───────────────────────────────────────────────
148
156
  export const ScoutStateSchema = z.object({
@@ -154,6 +162,7 @@ export const ScoutStateSchema = z.object({
154
162
  mergedPRs: z.array(StoredMergedPRSchema).default([]),
155
163
  closedPRs: z.array(StoredClosedPRSchema).default([]),
156
164
  savedResults: z.array(SavedCandidateSchema).default([]),
165
+ skippedIssues: z.array(SkippedIssueSchema).default([]),
157
166
  lastSearchAt: z.string().optional(),
158
167
  lastRunAt: z.string().default(() => new Date().toISOString()),
159
168
  gistId: 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
  *