@oss-scout/core 0.9.0 → 0.9.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.
@@ -21,7 +21,7 @@ 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, fetchIssuesFromMaintainedRepos, filterVetAndScore, fetchIssuesFromKnownRepos, searchWithChunkedLabels, } from "./search-phases.js";
24
+ import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, fetchIssuesFromMaintainedRepos, filterVetAndScore, fetchIssuesFromKnownRepos, searchAcrossLanguagesAndLabels, } from "./search-phases.js";
25
25
  const MODULE = "issue-discovery";
26
26
  /** If remaining search quota is below this, skip heavy phases (2, 3). */
27
27
  const LOW_BUDGET_THRESHOLD = 20;
@@ -83,7 +83,7 @@ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssue
83
83
  };
84
84
  }
85
85
  /** Phase 2: General label-filtered search with multi-tier interleaving. */
86
- async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQualifiers, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
86
+ async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
87
87
  info(MODULE, "Phase 2: General issue search...");
88
88
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
89
89
  // Build per-tier label groups. Multi-tier when 2+ scopes; single-tier otherwise.
@@ -112,7 +112,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQual
112
112
  let rateLimitHit = false;
113
113
  for (const { tier, tierLabels } of tierLabelGroups) {
114
114
  try {
115
- const allItems = await searchWithChunkedLabels(octokit, tierLabels, 0, (labelQ) => `${baseQualifiers} ${labelQ}`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
115
+ const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
116
116
  info(MODULE, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
117
117
  const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
118
118
  tierResults.push(tierCandidates);
@@ -337,9 +337,6 @@ export class IssueDiscovery {
337
337
  const langQuery = isAnyLanguage
338
338
  ? ""
339
339
  : languages.map((l) => `language:${l}`).join(" ");
340
- const baseQualifiers = `is:issue is:open ${langQuery} no:assignee`
341
- .replace(/ +/g, " ")
342
- .trim();
343
340
  // Build reusable filter
344
341
  const aiBlocklisted = new Set(config.aiPolicyBlocklist);
345
342
  if (aiBlocklisted.size > 0) {
@@ -427,7 +424,7 @@ export class IssueDiscovery {
427
424
  info(MODULE, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
428
425
  }
429
426
  const remaining = maxResults - allCandidates.length;
430
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
427
+ const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
431
428
  allCandidates.push(...result.candidates);
432
429
  phaseErrors["2"] = result.error;
433
430
  if (result.rateLimitHit)
@@ -61,6 +61,30 @@ export declare function fetchIssuesFromKnownRepos(octokit: Octokit, vetter: Issu
61
61
  * @param perPage Number of results per API call
62
62
  */
63
63
  export declare function searchWithChunkedLabels(octokit: Octokit, labels: string[], reservedOps: number, buildQuery: (labelQuery: string) => string, perPage: number): Promise<GitHubSearchItem[]>;
64
+ /**
65
+ * Build per-call language qualifier strings, fanning out across languages
66
+ * when a multi-language + labels combination would trip GitHub Search's
67
+ * empty-result edge case (multi-`language:` AND with a label OR-group
68
+ * silently returns 0 — see https://github.com/costajohnt/oss-autopilot/issues/1331).
69
+ */
70
+ export declare function buildLanguageVariants(languages: string[], isAnyLanguage: boolean, hasLabels: boolean): string[];
71
+ /**
72
+ * Search across languages with label chunking, deduplicating results.
73
+ *
74
+ * Fans out one query per language when 2+ languages are paired with labels
75
+ * (works around a GitHub Search backend edge case where the multi-language
76
+ * AND combined with a label OR-group returns 0). For each language variant,
77
+ * delegates to searchWithChunkedLabels to keep within GitHub's 5-operator limit.
78
+ *
79
+ * @param octokit Authenticated Octokit instance
80
+ * @param languages Configured languages (used as `language:X` qualifiers)
81
+ * @param isAnyLanguage When true, skip language qualifiers entirely
82
+ * @param labels Label list passed to searchWithChunkedLabels
83
+ * @param buildBaseQuery Builds the query prefix from a language qualifier string;
84
+ * e.g. `(langQ) => `is:issue is:open ${langQ} no:assignee`.trim()`
85
+ * @param perPage Results per API call
86
+ */
87
+ export declare function searchAcrossLanguagesAndLabels(octokit: Octokit, languages: string[], isAnyLanguage: boolean, labels: string[], buildBaseQuery: (langQuery: string) => string, perPage: number): Promise<GitHubSearchItem[]>;
64
88
  /**
65
89
  * Shared pipeline: spam-filter, repo-exclusion, vetting, and star-count filter.
66
90
  * Used by Phases 2 and 3 to convert raw search results into vetted candidates.
@@ -291,6 +291,56 @@ export async function searchWithChunkedLabels(octokit, labels, reservedOps, buil
291
291
  }
292
292
  return allItems;
293
293
  }
294
+ /**
295
+ * Build per-call language qualifier strings, fanning out across languages
296
+ * when a multi-language + labels combination would trip GitHub Search's
297
+ * empty-result edge case (multi-`language:` AND with a label OR-group
298
+ * silently returns 0 — see https://github.com/costajohnt/oss-autopilot/issues/1331).
299
+ */
300
+ export function buildLanguageVariants(languages, isAnyLanguage, hasLabels) {
301
+ if (isAnyLanguage || languages.length === 0)
302
+ return [""];
303
+ if (languages.length === 1)
304
+ return [`language:${languages[0]}`];
305
+ if (!hasLabels)
306
+ return [languages.map((l) => `language:${l}`).join(" ")];
307
+ return languages.map((l) => `language:${l}`);
308
+ }
309
+ /**
310
+ * Search across languages with label chunking, deduplicating results.
311
+ *
312
+ * Fans out one query per language when 2+ languages are paired with labels
313
+ * (works around a GitHub Search backend edge case where the multi-language
314
+ * AND combined with a label OR-group returns 0). For each language variant,
315
+ * delegates to searchWithChunkedLabels to keep within GitHub's 5-operator limit.
316
+ *
317
+ * @param octokit Authenticated Octokit instance
318
+ * @param languages Configured languages (used as `language:X` qualifiers)
319
+ * @param isAnyLanguage When true, skip language qualifiers entirely
320
+ * @param labels Label list passed to searchWithChunkedLabels
321
+ * @param buildBaseQuery Builds the query prefix from a language qualifier string;
322
+ * e.g. `(langQ) => `is:issue is:open ${langQ} no:assignee`.trim()`
323
+ * @param perPage Results per API call
324
+ */
325
+ export async function searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, labels, buildBaseQuery, perPage) {
326
+ const langVariants = buildLanguageVariants(languages, isAnyLanguage, labels.length > 0);
327
+ const seenUrls = new Set();
328
+ const allItems = [];
329
+ for (let i = 0; i < langVariants.length; i++) {
330
+ if (i > 0)
331
+ await sleep(INTER_QUERY_DELAY_MS);
332
+ const items = await searchWithChunkedLabels(octokit, labels, 0, (labelQ) => `${buildBaseQuery(langVariants[i])} ${labelQ}`
333
+ .replace(/ +/g, " ")
334
+ .trim(), perPage);
335
+ for (const item of items) {
336
+ if (!seenUrls.has(item.html_url)) {
337
+ seenUrls.add(item.html_url);
338
+ allItems.push(item);
339
+ }
340
+ }
341
+ }
342
+ return allItems;
343
+ }
294
344
  /**
295
345
  * Shared pipeline: spam-filter, repo-exclusion, vetting, and star-count filter.
296
346
  * Used by Phases 2 and 3 to convert raw search results into vetted candidates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.9.0",
3
+ "version": "0.9.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": {