@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.bundle.cjs +70 -64
- package/dist/cli.js +19 -129
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +2 -0
- package/dist/core/anti-llm-policy.js +3 -30
- package/dist/core/issue-discovery.d.ts +10 -1
- package/dist/core/issue-discovery.js +55 -43
- package/dist/core/issue-eligibility.d.ts +2 -1
- package/dist/core/issue-eligibility.js +6 -3
- package/dist/core/issue-vetting.d.ts +10 -1
- package/dist/core/issue-vetting.js +12 -2
- package/dist/core/personalization.d.ts +27 -12
- package/dist/core/personalization.js +50 -18
- package/dist/core/preference-fields.js +2 -0
- package/dist/core/probe-repo-file.d.ts +47 -0
- package/dist/core/probe-repo-file.js +57 -0
- package/dist/core/repo-health.js +9 -17
- package/dist/core/roadmap.js +11 -21
- package/dist/core/schemas.d.ts +4 -0
- package/dist/core/schemas.js +9 -0
- package/dist/core/search-phases.d.ts +4 -3
- package/dist/core/search-phases.js +10 -7
- package/dist/core/types.d.ts +15 -0
- package/dist/formatters/human.d.ts +60 -0
- package/dist/formatters/human.js +199 -0
- package/dist/scout.d.ts +24 -10
- package/dist/scout.js +29 -14
- package/package.json +1 -1
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(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
263
|
+
console.log(RESULTS_EMPTY_MESSAGE);
|
|
317
264
|
return;
|
|
318
265
|
}
|
|
319
|
-
console.log(
|
|
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(
|
|
347
|
+
console.log(VET_LIST_EMPTY_MESSAGE);
|
|
412
348
|
return;
|
|
413
349
|
}
|
|
414
|
-
console.log(
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -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 {
|
|
11
|
-
import { warn } from "./logger.js";
|
|
10
|
+
import { getHttpStatusCode, isRateLimitError } from "./errors.js";
|
|
12
11
|
import { getHttpCache, versionedCacheKey } from "./http-cache.js";
|
|
13
|
-
|
|
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) =>
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
505
|
-
// `personalization` marker
|
|
506
|
-
//
|
|
507
|
-
// a no-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|