@oss-scout/core 1.1.0 → 1.2.1

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.
@@ -28,6 +28,29 @@ const MODULE = "issue-discovery";
28
28
  const LOW_BUDGET_THRESHOLD = 20;
29
29
  /** If remaining search quota is below this, only run Phase 0. */
30
30
  const CRITICAL_BUDGET_THRESHOLD = 10;
31
+ /**
32
+ * Page size for Phase 0 (repos the user has contributed to). Larger than the
33
+ * default 5 so the backlog of open issues in known repos is reachable, not
34
+ * just the 5 newest-created. One `listForRepo` call regardless of page size,
35
+ * so this widens the candidate pool at no extra REST cost.
36
+ */
37
+ const PHASE0_PER_PAGE = 30;
38
+ /**
39
+ * Max issue age (by last activity) for Phase 0 contributed repos. Relaxed well
40
+ * past the default `maxIssueAgeDays` (90) because in a repo the user already
41
+ * knows, an older-but-still-open issue is still worth evaluating — the vetter
42
+ * screens staleness, existing PRs, and claims downstream.
43
+ */
44
+ const CONTRIBUTED_REPO_MAX_AGE_DAYS = 365;
45
+ /**
46
+ * Cap on Phase 0's share of `maxResults`. Phase 0 (contributed repos) fetches
47
+ * deeply (`PHASE0_PER_PAGE`) and can otherwise fill the entire result budget,
48
+ * which makes the `allCandidates.length < maxResults` gate false for every
49
+ * later phase so starred (Phase 1) and broad (Phases 2/3) never run. Reserving
50
+ * half the budget for the other strategies keeps each search round varied
51
+ * instead of returning only contributed-repo results.
52
+ */
53
+ const PHASE0_MAX_SHARE = 0.5;
31
54
  /** Build a reusable filter function from config. */
32
55
  function buildIssueFilter(config) {
33
56
  return (items) => {
@@ -63,8 +86,8 @@ function buildIssueFilter(config) {
63
86
  }
64
87
  /** Phase 0: Search repos where user has merged PRs (highest merge probability). */
65
88
  async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
66
- info(MODULE, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
67
- const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues);
89
+ info(MODULE, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter, ${PHASE0_PER_PAGE}/repo)...`);
90
+ const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues, PHASE0_PER_PAGE);
68
91
  info(MODULE, `Found ${candidates.length} candidates from merged-PR repos`);
69
92
  return {
70
93
  candidates,
@@ -374,15 +397,24 @@ export class IssueDiscovery {
374
397
  if (aiBlocklisted.size > 0) {
375
398
  debug(MODULE, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(", ")}`);
376
399
  }
377
- const filterIssues = buildIssueFilter({
400
+ const baseFilterConfig = {
378
401
  excludedRepos: new Set(config.excludeRepos.map((r) => r.toLowerCase())),
379
402
  excludeOrgs: new Set((config.excludeOrgs ?? []).map((o) => o.toLowerCase())),
380
403
  aiBlocklisted,
381
404
  lowScoringRepos,
382
405
  skippedUrls: options.skippedUrls ?? new Set(),
383
- maxAgeDays: config.maxIssueAgeDays || 90,
384
406
  now: new Date(),
385
407
  includeDocIssues: config.includeDocIssues ?? true,
408
+ };
409
+ const filterIssues = buildIssueFilter({
410
+ ...baseFilterConfig,
411
+ maxAgeDays: config.maxIssueAgeDays || 90,
412
+ });
413
+ // Phase 0 (contributed repos) gets a relaxed age window so the existing
414
+ // backlog surfaces, not just issues active in the last 90 days.
415
+ const filterIssuesPhase0 = buildIssueFilter({
416
+ ...baseFilterConfig,
417
+ maxAgeDays: CONTRIBUTED_REPO_MAX_AGE_DAYS,
386
418
  });
387
419
  // Phase 0: Repos the user has engaged with — merged PRs first (strongest
388
420
  // signal), then open PRs (active engagement even without a merge yet).
@@ -398,10 +430,21 @@ export class IssueDiscovery {
398
430
  break;
399
431
  }
400
432
  const phase0RepoSet = new Set(phase0Repos);
433
+ // Only cap Phase 0 when a later phase can actually consume the reserved
434
+ // budget — otherwise (no starred repos, broad/maintained disabled) the
435
+ // reservation would just shrink the result set with nothing to fill it.
436
+ const otherStrategiesCanRun = (starredRepos.length > 0 && enabledStrategies.has("starred")) ||
437
+ enabledStrategies.has("broad") ||
438
+ enabledStrategies.has("maintained");
401
439
  if (phase0Repos.length > 0 && enabledStrategies.has("merged")) {
402
- const remaining = maxResults - allCandidates.length;
440
+ // Cap Phase 0's share so it can't consume the whole budget and starve
441
+ // the starred/broad phases (which gate on allCandidates < maxResults).
442
+ const phase0Cap = otherStrategiesCanRun
443
+ ? Math.max(1, Math.ceil(maxResults * PHASE0_MAX_SHARE))
444
+ : maxResults;
445
+ const remaining = Math.min(maxResults - allCandidates.length, phase0Cap);
403
446
  if (remaining > 0) {
404
- const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssues);
447
+ const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssuesPhase0);
405
448
  recordPhaseResult("0", result);
406
449
  }
407
450
  strategiesUsed.push("merged");
@@ -43,7 +43,7 @@ export declare function fetchIssuesFromMaintainedRepos(octokit: Octokit, repos:
43
43
  * calls `GET /repos/{owner}/{repo}/issues` which counts against the much
44
44
  * larger Core API rate limit and avoids consuming the scarce Search quota.
45
45
  */
46
- export declare function fetchIssuesFromKnownRepos(octokit: Octokit, vetter: IssueVetter, repos: string[], labels: string[], maxResults: number, priority: SearchPriority, filterFn: (items: GitHubSearchItem[]) => GitHubSearchItem[]): Promise<{
46
+ export declare function fetchIssuesFromKnownRepos(octokit: Octokit, vetter: IssueVetter, repos: string[], labels: string[], maxResults: number, priority: SearchPriority, filterFn: (items: GitHubSearchItem[]) => GitHubSearchItem[], perPage?: number): Promise<{
47
47
  candidates: IssueCandidate[];
48
48
  allReposFailed: boolean;
49
49
  rateLimitHit: boolean;
@@ -186,7 +186,7 @@ export async function fetchIssuesFromMaintainedRepos(octokit, repos, minStars, m
186
186
  * calls `GET /repos/{owner}/{repo}/issues` which counts against the much
187
187
  * larger Core API rate limit and avoids consuming the scarce Search quota.
188
188
  */
189
- export async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResults, priority, filterFn) {
189
+ export async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResults, priority, filterFn, perPage = 5) {
190
190
  const candidates = [];
191
191
  let failedRepos = 0;
192
192
  let rateLimitFailures = 0;
@@ -213,7 +213,7 @@ export async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels,
213
213
  state: "open",
214
214
  sort: "created",
215
215
  direction: "desc",
216
- per_page: 5,
216
+ per_page: perPage,
217
217
  ...(label !== undefined ? { labels: label } : {}),
218
218
  });
219
219
  for (const issue of response.data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin",
5
5
  "type": "module",
6
6
  "bin": {