@oss-scout/core 1.0.0 → 1.1.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.js CHANGED
@@ -6,6 +6,7 @@ import { Command } from "commander";
6
6
  import { enableDebug } from "./core/logger.js";
7
7
  import { getCLIVersion } from "./core/utils.js";
8
8
  import { formatJsonSuccess, formatJsonError } from "./formatters/json.js";
9
+ import { renderSearch, renderFeatures, renderResults, renderVetList, renderVet, RESULTS_EMPTY_MESSAGE, VET_LIST_EMPTY_MESSAGE, } from "./formatters/human.js";
9
10
  import { ValidationError, errorMessage, resolveErrorCode, } from "./core/errors.js";
10
11
  import { hasLocalState, loadLocalState, saveLocalState, } from "./core/local-state.js";
11
12
  import { CONCRETE_STRATEGIES, SearchStrategySchema } from "./core/schemas.js";
@@ -30,14 +31,6 @@ async function runAction(options, body) {
30
31
  handleCommandError(err, options);
31
32
  }
32
33
  }
33
- /** Emoji for a vetting recommendation, shared by the search and vet renderers. */
34
- function recommendationIcon(recommendation) {
35
- if (recommendation === "approve")
36
- return "✅";
37
- if (recommendation === "skip")
38
- return "❌";
39
- return "⚠️";
40
- }
41
34
  const program = new Command();
42
35
  program
43
36
  .name("oss-scout")
@@ -116,6 +109,8 @@ program
116
109
  .option("--strategy <strategies>", `Search strategies (${CONCRETE_STRATEGIES.join(",")},all). Defaults to the defaultStrategy preference, or all.`)
117
110
  .option("--prefer-languages <list>", "Comma-separated languages to soft-boost in ranking (#1244). Candidates whose repo language matches sort above equally-recommended non-matches. Does not filter results.")
118
111
  .option("--prefer-repos <list>", "Comma-separated `owner/repo` slugs to soft-boost in ranking (#1244). Stronger weight than language match. Does not filter results.")
112
+ .option("--avoid-repos <list>", "Comma-separated `owner/repo` slugs to soft-penalize in ranking (#168). Milder than excludeRepos: pushes them down but does not filter them out.")
113
+ .option("--boost-issue-types <list>", "Comma-separated issue label types to soft-boost in ranking (#168), case-insensitive (e.g. `bug,good first issue`). Does not filter results.")
119
114
  .option("--diversity-ratio <n>", "Fraction of result slots (0-1) reserved for candidates that matched NEITHER preference list (#1244). Counterweights echo-chamber bias as boosts accumulate. Default 0 (disabled).")
120
115
  .action(async (count, options) => runAction(options, async () => {
121
116
  if (!hasLocalState() && !options.json) {
@@ -175,6 +170,8 @@ program
175
170
  strategies,
176
171
  preferLanguages: splitCsv(options.preferLanguages),
177
172
  preferRepos: splitCsv(options.preferRepos),
173
+ avoidRepos: splitCsv(options.avoidRepos),
174
+ boostIssueTypes: splitCsv(options.boostIssueTypes),
178
175
  diversityRatio,
179
176
  });
180
177
  if (options.json) {
@@ -182,30 +179,10 @@ program
182
179
  }
183
180
  else {
184
181
  // Human-readable output
185
- console.log(`\nFound ${results.candidates.length} issue candidates:\n`);
186
- for (const c of results.candidates) {
187
- const icon = recommendationIcon(c.recommendation);
188
- const stalledTag = c.linkedPR?.isStalled
189
- ? " (stalled PR, revive opportunity)"
190
- : "";
191
- // Personalization tag (#1244). A candidate is either boosted
192
- // (matched a preference) or a diversity slot (matched none and
193
- // filled a reserved slot); never both.
194
- let personalizationTag = "";
195
- if (c.boostScore && c.boostReasons && c.boostReasons.length > 0) {
196
- personalizationTag = ` [boosted: ${c.boostReasons.join("; ")}]`;
197
- }
198
- else if (c.diversitySlot) {
199
- personalizationTag = " [diversity slot]";
200
- }
201
- console.log(` ${icon} ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100]${personalizationTag}${stalledTag}`);
202
- console.log(` ${c.issue.title}`);
203
- console.log(` ${c.issue.url}`);
204
- if (c.repoScore) {
205
- console.log(` Repo: ${c.repoScore.score}/10, ${c.repoScore.mergedPRCount} merged PRs`);
206
- }
207
- console.log();
208
- }
182
+ console.log(renderSearch(results));
183
+ // Rate-limit warning stays on stderr (NOT folded into the stdout
184
+ // render), so --json stdout purity and the stdout/stderr split are
185
+ // both preserved.
209
186
  if (results.rateLimitWarning) {
210
187
  console.error(`\n⚠️ ${results.rateLimitWarning}`);
211
188
  }
@@ -252,41 +229,11 @@ program
252
229
  console.log(formatJsonSuccess(result));
253
230
  }
254
231
  else {
255
- const total = result.quickWins.length + result.biggerBets.length;
256
- if (result.message) {
257
- console.log(`\n${result.message}\n`);
258
- }
259
- if (total === 0)
260
- return;
261
- const headerScope = options.broad
262
- ? "across the ecosystem"
263
- : "in your anchor repos";
264
- console.log(`\n🎯 Feature opportunities ${headerScope} (${result.quickWins.length} quick wins + ${result.biggerBets.length} bigger bets)\n`);
265
- if (!options.broad) {
266
- console.log(`Anchor repos: ${result.anchorRepos.join(", ")}\n`);
267
- }
268
- if (result.quickWins.length) {
269
- console.log("── Quick wins ─────────────────────────────────────────");
270
- for (const c of result.quickWins) {
271
- const stalledTag = c.linkedPR?.isStalled
272
- ? " (stalled PR, revive opportunity)"
273
- : "";
274
- console.log(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
275
- console.log(` ${c.issue.url}`);
276
- }
277
- console.log("");
278
- }
279
- if (result.biggerBets.length) {
280
- console.log("── Bigger bets ────────────────────────────────────────");
281
- for (const c of result.biggerBets) {
282
- const stalledTag = c.linkedPR?.isStalled
283
- ? " (stalled PR, revive opportunity)"
284
- : "";
285
- console.log(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
286
- console.log(` ${c.issue.url}`);
287
- }
288
- console.log("");
289
- }
232
+ // renderFeatures returns "" only when there is no message AND
233
+ // nothing to list; guard so the caller never logs a blank line.
234
+ const out = renderFeatures(result, { broad: options.broad });
235
+ if (out)
236
+ console.log(out);
290
237
  }
291
238
  }));
292
239
  // ── results command ────────────────────────────────────────────────
@@ -313,21 +260,10 @@ resultsCmd
313
260
  return;
314
261
  }
315
262
  if (results.length === 0) {
316
- console.log("\nNo saved results. Run `oss-scout search` to find issues.\n");
263
+ console.log(RESULTS_EMPTY_MESSAGE);
317
264
  return;
318
265
  }
319
- console.log(`\nSaved results (${results.length}):\n`);
320
- console.log(" Score Repo Issue Recommendation Title");
321
- console.log(" ───── ──────────────────────────────── ────── ────────────── ─────");
322
- for (const r of results) {
323
- const score = String(r.viabilityScore).padStart(3);
324
- const repo = r.repo.padEnd(32).slice(0, 32);
325
- const issue = `#${r.number}`.padEnd(6);
326
- const rec = r.recommendation.padEnd(14);
327
- const title = r.title.length > 50 ? r.title.slice(0, 47) + "..." : r.title;
328
- console.log(` ${score} ${repo} ${issue} ${rec} ${title}`);
329
- }
330
- console.log();
266
+ console.log(renderResults(results));
331
267
  }));
332
268
  resultsCmd
333
269
  .command("clear")
@@ -408,35 +344,10 @@ program
408
344
  }
409
345
  else {
410
346
  if (result.results.length === 0) {
411
- console.log("\nNo saved results to vet. Run `oss-scout search` first.\n");
347
+ console.log(VET_LIST_EMPTY_MESSAGE);
412
348
  return;
413
349
  }
414
- console.log(`\nVet-list results (${result.summary.total}):\n`);
415
- for (const r of result.results) {
416
- const icon = r.status === "still_available"
417
- ? "✅"
418
- : r.status === "claimed"
419
- ? "🔒"
420
- : r.status === "has_pr"
421
- ? "🔀"
422
- : r.status === "closed"
423
- ? "🚫"
424
- : "❌";
425
- const score = r.ok ? ` [${r.viabilityScore}/100]` : "";
426
- console.log(` ${icon} ${r.repo}#${r.number} — ${r.status}${score}`);
427
- console.log(` ${r.title}`);
428
- }
429
- if (result.transitions.length > 0) {
430
- console.log(`\n🔔 Changes since last check (${result.transitions.length}):`);
431
- for (const t of result.transitions) {
432
- console.log(` ${t.repo}#${t.number}: ${t.from} → ${t.to}`);
433
- }
434
- }
435
- console.log(`\nSummary: ${result.summary.stillAvailable} available, ${result.summary.claimed} claimed, ${result.summary.hasPR} has PR, ${result.summary.closed} closed, ${result.summary.errors} errors`);
436
- if (result.prunedCount != null) {
437
- console.log(`Pruned ${result.prunedCount} unavailable issues from saved results.`);
438
- }
439
- console.log();
350
+ console.log(renderVetList(result));
440
351
  }
441
352
  }));
442
353
  // ── skip command ───────────────────────────────────────────────────
@@ -538,28 +449,7 @@ program
538
449
  console.log(formatJsonSuccess(result));
539
450
  }
540
451
  else {
541
- const icon = recommendationIcon(result.recommendation);
542
- console.log(`\n${icon} ${result.issue.repo}#${result.issue.number}: ${result.recommendation.toUpperCase()}`);
543
- console.log(` ${result.issue.title}`);
544
- console.log(` ${result.issue.url}\n`);
545
- if (result.reasonsToApprove.length > 0) {
546
- console.log("Reasons to approve:");
547
- for (const r of result.reasonsToApprove)
548
- console.log(` + ${r}`);
549
- }
550
- if (result.reasonsToSkip.length > 0) {
551
- console.log("Reasons to skip:");
552
- for (const r of result.reasonsToSkip)
553
- console.log(` - ${r}`);
554
- }
555
- if (result.projectHealth.checkFailed) {
556
- console.log(`\nProject health: unknown (check failed: ${result.projectHealth.failureReason})`);
557
- }
558
- else {
559
- console.log(`\nProject health: ${result.projectHealth.isActive ? "Active" : "Inactive"}`);
560
- console.log(` Last commit: ${result.projectHealth.daysSinceLastCommit} days ago`);
561
- console.log(` CI status: ${result.projectHealth.ciStatus}`);
562
- }
452
+ console.log(renderVet(result));
563
453
  }
564
454
  }));
565
455
  program.parse();
@@ -64,6 +64,10 @@ interface SearchCommandOptions {
64
64
  preferLanguages?: string[];
65
65
  /** Soft sort boost for candidates in these `owner/repo` slugs (#1244). */
66
66
  preferRepos?: string[];
67
+ /** Soft sort penalty for candidates in these `owner/repo` slugs (#168). */
68
+ avoidRepos?: string[];
69
+ /** Soft sort boost for candidates whose labels match these types (#168). */
70
+ boostIssueTypes?: string[];
67
71
  /** Diversity counterweight: fraction of slots reserved for unboosted candidates (#1244). */
68
72
  diversityRatio?: number;
69
73
  }
@@ -10,6 +10,8 @@ export async function runSearch(options) {
10
10
  strategies: options.strategies,
11
11
  preferLanguages: options.preferLanguages,
12
12
  preferRepos: options.preferRepos,
13
+ avoidRepos: options.avoidRepos,
14
+ boostIssueTypes: options.boostIssueTypes,
13
15
  diversityRatio: options.diversityRatio,
14
16
  });
15
17
  scout.saveResults(result.candidates);
@@ -7,10 +7,9 @@
7
7
  * can rely on a structured `AntiLLMPolicyResult` rather than re-implementing
8
8
  * the scan in agent prose.
9
9
  */
10
- import { errorMessage, getHttpStatusCode, isRateLimitError, rethrowIfFatal, } from "./errors.js";
11
- import { warn } from "./logger.js";
10
+ import { getHttpStatusCode, isRateLimitError } from "./errors.js";
12
11
  import { getHttpCache, versionedCacheKey } from "./http-cache.js";
13
- const MODULE = "anti-llm-policy";
12
+ import { probeRepoFile } from "./probe-repo-file.js";
14
13
  /** TTL for cached anti-LLM policy scan results (1 hour). Policy docs change rarely. */
15
14
  const POLICY_SCAN_CACHE_TTL_MS = 60 * 60 * 1000;
16
15
  /**
@@ -85,39 +84,13 @@ const SOURCE_FILE_FAMILIES = [
85
84
  paths: ["README.md", "readme.md", "Readme.md"],
86
85
  },
87
86
  ];
88
- /**
89
- * Fetch one path's raw text content. The `transient` flag distinguishes a
90
- * clean miss (404 — file absent) from a degraded miss (5xx, network) so the
91
- * caller can decide whether to cache "no policy" or retry. Throws on
92
- * 401/auth and rate-limit per documented project error strategy.
93
- */
94
- async function fetchFileText(octokit, owner, repo, path) {
95
- try {
96
- const { data } = await octokit.repos.getContent({ owner, repo, path });
97
- if ("content" in data && typeof data.content === "string") {
98
- return {
99
- text: Buffer.from(data.content, "base64").toString("utf-8"),
100
- transient: false,
101
- };
102
- }
103
- return { text: null, transient: false };
104
- }
105
- catch (error) {
106
- const status = getHttpStatusCode(error);
107
- if (status === 404)
108
- return { text: null, transient: false };
109
- rethrowIfFatal(error);
110
- warn(MODULE, `Unexpected error fetching ${path} from ${owner}/${repo}: ${errorMessage(error)}`);
111
- return { text: null, transient: true };
112
- }
113
- }
114
87
  /**
115
88
  * Fetch the first available file from a family. Probes are issued in parallel,
116
89
  * but auth/rate-limit rejections re-throw so the IssueVetter's existing
117
90
  * rate-limit handling kicks in instead of silently caching a wrong answer.
118
91
  */
119
92
  async function fetchFamilyText(octokit, owner, repo, paths) {
120
- const results = await Promise.allSettled(paths.map((p) => fetchFileText(octokit, owner, repo, p)));
93
+ const results = await Promise.allSettled(paths.map((p) => probeRepoFile(octokit, owner, repo, p)));
121
94
  let hadTransientFailure = false;
122
95
  for (const result of results) {
123
96
  if (result.status === "fulfilled") {
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * All state is injected via constructor parameters (ScoutStateReader + ScoutPreferences).
13
13
  */
14
+ import { type SearchBudgetTracker } from "./search-budget.js";
14
15
  import { type IssueCandidate } from "./types.js";
15
16
  import type { ScoutPreferences, SearchStrategy } from "./schemas.js";
16
17
  import { type ScoutStateReader } from "./issue-vetting.js";
@@ -31,14 +32,20 @@ export declare class IssueDiscovery {
31
32
  private octokit;
32
33
  private githubToken;
33
34
  private vetter;
35
+ private budgetTracker;
34
36
  /** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
35
37
  rateLimitWarning: string | null;
36
38
  /**
37
39
  * @param githubToken - GitHub personal access token or token from `gh auth token`
38
40
  * @param preferences - User's search preferences (languages, labels, scopes, etc.)
39
41
  * @param stateReader - Read-only interface for accessing scout state (merged PRs, starred repos, etc.)
42
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
43
+ * singleton so existing callers behave identically. A long-lived host
44
+ * serving concurrent searches can inject a per-search instance so one
45
+ * search's init() no longer resets the budget state of another (the
46
+ * shared-singleton concurrency hazard, #156).
40
47
  */
41
- constructor(githubToken: string, preferences: ScoutPreferences, stateReader: ScoutStateReader);
48
+ constructor(githubToken: string, preferences: ScoutPreferences, stateReader: ScoutStateReader, budgetTracker?: SearchBudgetTracker);
42
49
  /**
43
50
  * Get starred repos from the state reader.
44
51
  * @returns Array of starred repo names in "owner/repo" format
@@ -76,6 +83,8 @@ export declare class IssueDiscovery {
76
83
  skippedUrls?: Set<string>;
77
84
  preferLanguages?: string[];
78
85
  preferRepos?: string[];
86
+ avoidRepos?: string[];
87
+ boostIssueTypes?: string[];
79
88
  diversityRatio?: number;
80
89
  interPhaseDelayMs?: number;
81
90
  broadPhaseDelayMs?: number;
@@ -12,7 +12,7 @@
12
12
  * All state is injected via constructor parameters (ScoutStateReader + ScoutPreferences).
13
13
  */
14
14
  import { getOctokit, checkRateLimit } from "./github.js";
15
- import { getSearchBudgetTracker } from "./search-budget.js";
15
+ import { getSearchBudgetTracker, } from "./search-budget.js";
16
16
  import { daysBetween, extractRepoFromUrl, sleep } from "./utils.js";
17
17
  import { SCOPE_LABELS, } from "./types.js";
18
18
  import { CONCRETE_STRATEGIES } from "./schemas.js";
@@ -87,7 +87,7 @@ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssue
87
87
  };
88
88
  }
89
89
  /** Phase 2: General label-filtered search with multi-tier interleaving. */
90
- async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
90
+ async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues, tracker) {
91
91
  info(MODULE, "Phase 2: General issue search...");
92
92
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
93
93
  // Build per-tier label groups. Multi-tier when 2+ scopes; single-tier otherwise.
@@ -116,7 +116,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
116
116
  let rateLimitHit = false;
117
117
  for (const { tier, tierLabels } of tierLabelGroups) {
118
118
  try {
119
- const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
119
+ const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3, tracker);
120
120
  info(MODULE, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
121
121
  const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
122
122
  tierResults.push(tierCandidates);
@@ -153,7 +153,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
153
153
  };
154
154
  }
155
155
  /** Phase 3: Actively maintained repos (REST-first, Search API fallback). */
156
- async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues) {
156
+ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues, tracker) {
157
157
  info(MODULE, "Phase 3: Searching actively maintained repos...");
158
158
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
159
159
  // Step 1: Try REST API with starred repos first (no Search API quota used)
@@ -196,7 +196,7 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
196
196
  sort: "updated",
197
197
  order: "desc",
198
198
  per_page: maxResults * 3,
199
- });
199
+ }, tracker);
200
200
  info(MODULE, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
201
201
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], maxResults, minStars, "Phase 3");
202
202
  info(MODULE, `Found ${candidates.length} candidates from maintained-repo search`);
@@ -236,19 +236,28 @@ export class IssueDiscovery {
236
236
  octokit;
237
237
  githubToken;
238
238
  vetter;
239
+ budgetTracker;
239
240
  /** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
240
241
  rateLimitWarning = null;
241
242
  /**
242
243
  * @param githubToken - GitHub personal access token or token from `gh auth token`
243
244
  * @param preferences - User's search preferences (languages, labels, scopes, etc.)
244
245
  * @param stateReader - Read-only interface for accessing scout state (merged PRs, starred repos, etc.)
246
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
247
+ * singleton so existing callers behave identically. A long-lived host
248
+ * serving concurrent searches can inject a per-search instance so one
249
+ * search's init() no longer resets the budget state of another (the
250
+ * shared-singleton concurrency hazard, #156).
245
251
  */
246
- constructor(githubToken, preferences, stateReader) {
252
+ constructor(githubToken, preferences, stateReader, budgetTracker = getSearchBudgetTracker()) {
247
253
  this.preferences = preferences;
248
254
  this.stateReader = stateReader;
249
255
  this.githubToken = githubToken;
250
256
  this.octokit = getOctokit(githubToken);
251
- this.vetter = new IssueVetter(this.octokit, this.stateReader);
257
+ this.budgetTracker = budgetTracker;
258
+ // Thread the same tracker into the vetter so the merged-PR Search API
259
+ // call (checkUserMergedPRsInRepo) pays the same budget as the search phases.
260
+ this.vetter = new IssueVetter(this.octokit, this.stateReader, this.budgetTracker);
252
261
  }
253
262
  /**
254
263
  * Get starred repos from the state reader.
@@ -300,9 +309,27 @@ export class IssueDiscovery {
300
309
  const allCandidates = [];
301
310
  const phaseErrors = {};
302
311
  let rateLimitHitDuringSearch = false;
312
+ // The standard inter-phase pause for rate-limit management. Phases 1, 2,
313
+ // and 3 all apply this identical delay before querying (Phase 0 is first,
314
+ // so it never waits). The broad phase wraps this with an extra cooldown.
315
+ const applyInterPhaseDelay = async () => {
316
+ if (interPhaseDelay > 0) {
317
+ info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
318
+ await sleep(interPhaseDelay);
319
+ }
320
+ };
321
+ // Fold a phase's result into the running totals. Every phase accumulates
322
+ // candidates, records its error under a stable key, and flips the
323
+ // rate-limit flag the same way; only the key and the result differ.
324
+ const recordPhaseResult = (key, result) => {
325
+ allCandidates.push(...result.candidates);
326
+ phaseErrors[key] = result.error;
327
+ if (result.rateLimitHit)
328
+ rateLimitHitDuringSearch = true;
329
+ };
303
330
  // Pre-flight rate limit check
304
331
  this.rateLimitWarning = null;
305
- const tracker = getSearchBudgetTracker();
332
+ const tracker = this.budgetTracker;
306
333
  let searchBudget = LOW_BUDGET_THRESHOLD - 1;
307
334
  try {
308
335
  const rateLimit = await checkRateLimit(this.githubToken);
@@ -375,10 +402,7 @@ export class IssueDiscovery {
375
402
  const remaining = maxResults - allCandidates.length;
376
403
  if (remaining > 0) {
377
404
  const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssues);
378
- allCandidates.push(...result.candidates);
379
- phaseErrors["0"] = result.error;
380
- if (result.rateLimitHit)
381
- rateLimitHitDuringSearch = true;
405
+ recordPhaseResult("0", result);
382
406
  }
383
407
  strategiesUsed.push("merged");
384
408
  }
@@ -387,19 +411,13 @@ export class IssueDiscovery {
387
411
  starredRepos.length > 0 &&
388
412
  searchBudget >= CRITICAL_BUDGET_THRESHOLD &&
389
413
  enabledStrategies.has("starred")) {
390
- if (interPhaseDelay > 0) {
391
- info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
392
- await sleep(interPhaseDelay);
393
- }
414
+ await applyInterPhaseDelay();
394
415
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
395
416
  if (reposToSearch.length > 0) {
396
417
  const remaining = maxResults - allCandidates.length;
397
418
  if (remaining > 0) {
398
419
  const result = await runPhase1(this.octokit, this.vetter, reposToSearch, labels, remaining, filterIssues);
399
- allCandidates.push(...result.candidates);
400
- phaseErrors["1"] = result.error;
401
- if (result.rateLimitHit)
402
- rateLimitHitDuringSearch = true;
420
+ recordPhaseResult("1", result);
403
421
  // Recorded only when the phase actually queried (#130)
404
422
  strategiesUsed.push("starred");
405
423
  }
@@ -424,10 +442,7 @@ export class IssueDiscovery {
424
442
  }
425
443
  else {
426
444
  // Always apply baseline inter-phase delay
427
- if (interPhaseDelay > 0) {
428
- info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
429
- await sleep(interPhaseDelay);
430
- }
445
+ await applyInterPhaseDelay();
431
446
  // Apply additional broad-phase cooldown, but skip if previous phases found nothing
432
447
  if (allCandidates.length > 0 && broadDelay > 0) {
433
448
  info(MODULE, `Waiting ${(broadDelay / 1000).toFixed(0)}s for rate limit cooldown before broad search...`);
@@ -437,11 +452,8 @@ export class IssueDiscovery {
437
452
  info(MODULE, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
438
453
  }
439
454
  const remaining = maxResults - allCandidates.length;
440
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
441
- allCandidates.push(...result.candidates);
442
- phaseErrors["2"] = result.error;
443
- if (result.rateLimitHit)
444
- rateLimitHitDuringSearch = true;
455
+ const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues, tracker);
456
+ recordPhaseResult("2", result);
445
457
  // Recorded only when the phase actually queried, not when the
446
458
  // skip-threshold branch short-circuited it (#130)
447
459
  strategiesUsed.push("broad");
@@ -451,16 +463,10 @@ export class IssueDiscovery {
451
463
  if (allCandidates.length < maxResults &&
452
464
  searchBudget >= LOW_BUDGET_THRESHOLD &&
453
465
  enabledStrategies.has("maintained")) {
454
- if (interPhaseDelay > 0) {
455
- info(MODULE, `Waiting ${(interPhaseDelay / 1000).toFixed(0)}s between phases for rate limit management...`);
456
- await sleep(interPhaseDelay);
457
- }
466
+ await applyInterPhaseDelay();
458
467
  const remaining = maxResults - allCandidates.length;
459
- const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues);
460
- allCandidates.push(...result.candidates);
461
- phaseErrors["3"] = result.error;
462
- if (result.rateLimitHit)
463
- rateLimitHitDuringSearch = true;
468
+ const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues, tracker);
469
+ recordPhaseResult("3", result);
464
470
  strategiesUsed.push("maintained");
465
471
  }
466
472
  // Build result / error summary
@@ -501,11 +507,17 @@ export class IssueDiscovery {
501
507
  `Found ${allCandidates.length} candidate${allCandidates.length === 1 ? "" : "s"} but some search phases were limited. ` +
502
508
  `Try again after the rate limit resets for complete results.`;
503
509
  }
504
- // Personalization annotation (#1244): tag matched candidates with a
505
- // `personalization` marker before sorting so the new sort tier has values
506
- // to read. Returns a new array (no in-place candidate mutation, #158);
507
- // a no-op when neither preference list is supplied.
508
- const ranked = annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
510
+ // Personalization annotation (#1244, extended #168): tag candidates with a
511
+ // net `personalization` marker (preferRepos/preferLanguages/boostIssueTypes
512
+ // add, avoidRepos subtracts) before sorting so the sort tier has values to
513
+ // read. Returns a new array (no in-place candidate mutation, #158); a no-op
514
+ // when none of the bias lists are supplied.
515
+ const ranked = annotateBoost(allCandidates, {
516
+ preferLanguages: options.preferLanguages,
517
+ preferRepos: options.preferRepos,
518
+ avoidRepos: options.avoidRepos,
519
+ boostIssueTypes: options.boostIssueTypes,
520
+ });
509
521
  // Sort by priority, recommendation, boost (#1244), then viability score
510
522
  ranked.sort((a, b) => {
511
523
  const priorityOrder = {
@@ -6,6 +6,7 @@
6
6
  * Extracted from issue-vetting.ts to isolate eligibility logic.
7
7
  */
8
8
  import { Octokit } from "@octokit/rest";
9
+ import { type SearchBudgetTracker } from "./search-budget.js";
9
10
  import type { CheckResult, LinkedPR } from "./types.js";
10
11
  /**
11
12
  * Result of the existing-PR check, including metadata for the first linked PR
@@ -29,7 +30,7 @@ export declare function checkNoExistingPR(octokit: Octokit, owner: string, repo:
29
30
  * Results are cached per-repo for 15 minutes to avoid redundant Search API
30
31
  * calls when multiple issues from the same repo are vetted.
31
32
  */
32
- export declare function checkUserMergedPRsInRepo(octokit: Octokit, owner: string, repo: string): Promise<number | null>;
33
+ export declare function checkUserMergedPRsInRepo(octokit: Octokit, owner: string, repo: string, tracker?: SearchBudgetTracker): Promise<number | null>;
33
34
  /**
34
35
  * Check whether an issue has been claimed by another contributor
35
36
  * by scanning recent comments for claim phrases.
@@ -9,7 +9,7 @@ import { paginateAll } from "./pagination.js";
9
9
  import { errorMessage, rethrowIfFatal } from "./errors.js";
10
10
  import { warn } from "./logger.js";
11
11
  import { getHttpCache, withInflightDedup, versionedCacheKey, } from "./http-cache.js";
12
- import { getSearchBudgetTracker } from "./search-budget.js";
12
+ import { getSearchBudgetTracker, } from "./search-budget.js";
13
13
  function isLinkedPREvent(e) {
14
14
  return e.event === "cross-referenced" && !!e.source?.issue?.pull_request;
15
15
  }
@@ -161,7 +161,11 @@ const MERGED_PR_CACHE_TTL_MS = 15 * 60 * 1000;
161
161
  * Results are cached per-repo for 15 minutes to avoid redundant Search API
162
162
  * calls when multiple issues from the same repo are vetted.
163
163
  */
164
- export async function checkUserMergedPRsInRepo(octokit, owner, repo) {
164
+ export async function checkUserMergedPRsInRepo(octokit, owner, repo,
165
+ // Optional injected budget tracker. Defaults to the shared singleton so
166
+ // existing callers keep the same global budget accounting; a host wanting
167
+ // per-search isolation threads its own tracker down from IssueVetter.
168
+ tracker = getSearchBudgetTracker()) {
165
169
  const cache = getHttpCache();
166
170
  const cacheKey = versionedCacheKey(`merged-prs:${owner}/${repo}`);
167
171
  // In-flight dedup: parallel vetting frequently hits several issues from
@@ -177,7 +181,6 @@ export async function checkUserMergedPRsInRepo(octokit, owner, repo) {
177
181
  return cached;
178
182
  }
179
183
  try {
180
- const tracker = getSearchBudgetTracker();
181
184
  await tracker.waitForBudget();
182
185
  try {
183
186
  // Use @me to search as the authenticated user
@@ -9,6 +9,7 @@
9
9
  import { Octokit } from "@octokit/rest";
10
10
  import { type SearchPriority, type IssueCandidate, type ProjectCategory, type ScoutPreferences, type ScoutState, type MergedPRRecord, type ClosedPRRecord, type OpenPRRecord } from "./types.js";
11
11
  import { type PrefetchedIssueCore } from "./issue-graphql.js";
12
+ import { type SearchBudgetTracker } from "./search-budget.js";
12
13
  /**
13
14
  * Feature-mode signals supplied by the caller (orchestrator) — the vetter
14
15
  * does NOT extract these from the GitHub issue itself. When passed, they
@@ -142,7 +143,15 @@ export declare function deriveRecommendation(input: RecommendationInput): Recomm
142
143
  export declare class IssueVetter {
143
144
  private octokit;
144
145
  private stateReader;
145
- constructor(octokit: Octokit, stateReader: ScoutStateReader);
146
+ private budgetTracker;
147
+ /**
148
+ * @param octokit - Authenticated Octokit instance
149
+ * @param stateReader - Read-only scout state interface
150
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
151
+ * singleton so existing callers behave identically; inject a per-search
152
+ * instance to isolate budget accounting in a long-lived concurrent host.
153
+ */
154
+ constructor(octokit: Octokit, stateReader: ScoutStateReader, budgetTracker?: SearchBudgetTracker);
146
155
  /**
147
156
  * Vet a specific issue — runs all checks and computes recommendation + viability score.
148
157
  * Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
@@ -16,6 +16,7 @@ import { checkProjectHealth, fetchContributionGuidelines, } from "./repo-health.
16
16
  import { fetchAndScanAntiLLMPolicy } from "./anti-llm-policy.js";
17
17
  import { prefetchIssueCores, issueCoreKey, } from "./issue-graphql.js";
18
18
  import { getHttpCache, versionedCacheKey } from "./http-cache.js";
19
+ import { getSearchBudgetTracker, } from "./search-budget.js";
19
20
  import { triageWithSLM, buildTriageInput, } from "./slm-triage.js";
20
21
  const MODULE = "issue-vetting";
21
22
  /** Vetting concurrency: kept low to reduce burst pressure on GitHub's secondary rate limit. */
@@ -119,9 +120,18 @@ export function deriveRecommendation(input) {
119
120
  export class IssueVetter {
120
121
  octokit;
121
122
  stateReader;
122
- constructor(octokit, stateReader) {
123
+ budgetTracker;
124
+ /**
125
+ * @param octokit - Authenticated Octokit instance
126
+ * @param stateReader - Read-only scout state interface
127
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
128
+ * singleton so existing callers behave identically; inject a per-search
129
+ * instance to isolate budget accounting in a long-lived concurrent host.
130
+ */
131
+ constructor(octokit, stateReader, budgetTracker = getSearchBudgetTracker()) {
123
132
  this.octokit = octokit;
124
133
  this.stateReader = stateReader;
134
+ this.budgetTracker = budgetTracker;
125
135
  }
126
136
  /**
127
137
  * Vet a specific issue — runs all checks and computes recommendation + viability score.
@@ -170,7 +180,7 @@ export class IssueVetter {
170
180
  fetchContributionGuidelines(this.octokit, owner, repo),
171
181
  hasMergedPRsInRepo
172
182
  ? Promise.resolve(0)
173
- : checkUserMergedPRsInRepo(this.octokit, owner, repo),
183
+ : checkUserMergedPRsInRepo(this.octokit, owner, repo, this.budgetTracker),
174
184
  ]);
175
185
  // Anti-LLM scan reuses the CONTRIBUTING text just fetched above —
176
186
  // dedup'd to avoid 4 redundant getContent calls on cold-cache repos.