@oss-scout/core 0.3.0 → 0.5.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/LICENSE +21 -0
- package/dist/cli.bundle.cjs +45 -45
- package/dist/commands/config.js +6 -0
- package/dist/core/bootstrap.d.ts +1 -0
- package/dist/core/bootstrap.js +39 -1
- package/dist/core/gist-state-store.d.ts +1 -1
- package/dist/core/gist-state-store.js +2 -1
- package/dist/core/issue-discovery.js +92 -29
- package/dist/core/issue-vetting.d.ts +2 -0
- package/dist/core/schemas.d.ts +17 -0
- package/dist/core/schemas.js +9 -0
- package/dist/core/search-phases.d.ts +21 -0
- package/dist/core/search-phases.js +157 -11
- package/dist/core/types.d.ts +7 -0
- package/dist/index.d.ts +2 -2
- package/dist/scout.d.ts +7 -1
- package/dist/scout.js +26 -0
- package/package.json +14 -14
- package/dist/core/concurrency.d.ts +0 -6
- package/dist/core/concurrency.js +0 -25
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* caching, spam-filtering, and batched repo search logic.
|
|
6
6
|
*/
|
|
7
7
|
import { SCOPE_LABELS, } from "./types.js";
|
|
8
|
-
import { errorMessage, isRateLimitError } from "./errors.js";
|
|
8
|
+
import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
|
|
9
9
|
import { debug, warn } from "./logger.js";
|
|
10
|
-
import { getHttpCache
|
|
10
|
+
import { getHttpCache } from "./http-cache.js";
|
|
11
11
|
import { detectLabelFarmingRepos, } from "./issue-filtering.js";
|
|
12
12
|
import { extractRepoFromUrl, sleep } from "./utils.js";
|
|
13
13
|
import { getSearchBudgetTracker } from "./search-budget.js";
|
|
@@ -95,20 +95,166 @@ const SEARCH_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
|
95
95
|
*/
|
|
96
96
|
export async function cachedSearchIssues(octokit, params) {
|
|
97
97
|
const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
const cache = getHttpCache();
|
|
99
|
+
// Check cache first
|
|
100
|
+
const cached = cache.getIfFresh(cacheKey, SEARCH_CACHE_TTL_MS);
|
|
101
|
+
if (cached) {
|
|
102
|
+
debug(MODULE, `Search cache hit for query`);
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
// Fetch from API
|
|
106
|
+
const tracker = getSearchBudgetTracker();
|
|
107
|
+
await tracker.waitForBudget();
|
|
108
|
+
let data;
|
|
109
|
+
try {
|
|
110
|
+
const response = await octokit.search.issuesAndPullRequests(params);
|
|
111
|
+
data = response.data;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
tracker.recordCall();
|
|
115
|
+
}
|
|
116
|
+
// Only cache non-empty results to prevent poisoning from rate-limited responses
|
|
117
|
+
if (data.items.length > 0) {
|
|
118
|
+
cache.set(cacheKey, "", data);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
debug(MODULE, `Skipping cache for empty search result (possible rate limit artifact)`);
|
|
122
|
+
}
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
// ── REST-based search functions ──
|
|
126
|
+
/**
|
|
127
|
+
* Fetch issues from maintained repos using REST API (no Search API quota).
|
|
128
|
+
*
|
|
129
|
+
* Checks each repo for recent push activity and star threshold,
|
|
130
|
+
* then fetches open issues via `GET /repos/{owner}/{repo}/issues`.
|
|
131
|
+
* Falls back to the caller to use Search API if this doesn't yield enough.
|
|
132
|
+
*/
|
|
133
|
+
export async function fetchIssuesFromMaintainedRepos(octokit, repos, minStars, maxResults) {
|
|
134
|
+
const items = [];
|
|
135
|
+
for (const repoFullName of repos) {
|
|
136
|
+
if (items.length >= maxResults * 3)
|
|
137
|
+
break;
|
|
138
|
+
const [owner, repo] = repoFullName.split("/");
|
|
139
|
+
if (!owner || !repo)
|
|
140
|
+
continue;
|
|
101
141
|
try {
|
|
102
|
-
const { data } = await octokit.
|
|
103
|
-
|
|
142
|
+
const { data: repoData } = await octokit.repos.get({ owner, repo });
|
|
143
|
+
if (!repoData.pushed_at)
|
|
144
|
+
continue;
|
|
145
|
+
const pushedAt = new Date(repoData.pushed_at);
|
|
146
|
+
const thirtyDaysAgo = new Date();
|
|
147
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
148
|
+
if (pushedAt < thirtyDaysAgo)
|
|
149
|
+
continue;
|
|
150
|
+
if ((repoData.stargazers_count ?? 0) < minStars)
|
|
151
|
+
continue;
|
|
152
|
+
if (repoData.archived)
|
|
153
|
+
continue;
|
|
154
|
+
const { data: issues } = await octokit.issues.listForRepo({
|
|
155
|
+
owner,
|
|
156
|
+
repo,
|
|
157
|
+
state: "open",
|
|
158
|
+
sort: "created",
|
|
159
|
+
direction: "desc",
|
|
160
|
+
per_page: 5,
|
|
161
|
+
});
|
|
162
|
+
// Filter out pull requests and assigned issues (REST endpoint returns both)
|
|
163
|
+
const realIssues = issues.filter((i) => !i.pull_request && !i.assignee);
|
|
164
|
+
for (const issue of realIssues) {
|
|
165
|
+
items.push({
|
|
166
|
+
html_url: issue.html_url,
|
|
167
|
+
repository_url: `https://api.github.com/repos/${repoFullName}`,
|
|
168
|
+
updated_at: issue.updated_at ?? "",
|
|
169
|
+
title: issue.title,
|
|
170
|
+
labels: issue.labels,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
await sleep(INTER_QUERY_DELAY_MS);
|
|
104
174
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (getHttpStatusCode(error) === 401)
|
|
177
|
+
throw error;
|
|
178
|
+
if (isRateLimitError(error)) {
|
|
179
|
+
warn(MODULE, `Rate limit hit fetching issues from ${repoFullName}:`, errorMessage(error));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
warn(MODULE, `Error fetching issues from ${repoFullName}:`, errorMessage(error));
|
|
108
183
|
}
|
|
109
|
-
}
|
|
184
|
+
}
|
|
185
|
+
return items;
|
|
110
186
|
}
|
|
111
187
|
// ── Search infrastructure ──
|
|
188
|
+
/**
|
|
189
|
+
* Fetch open issues from known repos using REST API (no Search API quota).
|
|
190
|
+
* Used by Phase 0 (merged-PR repos) and Phase 1 (starred repos).
|
|
191
|
+
*
|
|
192
|
+
* Instead of the Search API (`octokit.search.issuesAndPullRequests`), this
|
|
193
|
+
* calls `GET /repos/{owner}/{repo}/issues` which counts against the much
|
|
194
|
+
* larger Core API rate limit and avoids consuming the scarce Search quota.
|
|
195
|
+
*/
|
|
196
|
+
export async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResults, priority, filterFn) {
|
|
197
|
+
const candidates = [];
|
|
198
|
+
let failedRepos = 0;
|
|
199
|
+
let rateLimitFailures = 0;
|
|
200
|
+
for (let i = 0; i < repos.length; i++) {
|
|
201
|
+
if (candidates.length >= maxResults)
|
|
202
|
+
break;
|
|
203
|
+
// Delay between repos to avoid REST secondary rate limits
|
|
204
|
+
if (i > 0)
|
|
205
|
+
await sleep(INTER_QUERY_DELAY_MS);
|
|
206
|
+
const repoFullName = repos[i];
|
|
207
|
+
const [owner, repo] = repoFullName.split("/");
|
|
208
|
+
try {
|
|
209
|
+
const response = await octokit.issues.listForRepo({
|
|
210
|
+
owner,
|
|
211
|
+
repo,
|
|
212
|
+
state: "open",
|
|
213
|
+
sort: "created",
|
|
214
|
+
direction: "desc",
|
|
215
|
+
per_page: 5,
|
|
216
|
+
...(labels.length > 0 ? { labels: labels.join(",") } : {}),
|
|
217
|
+
});
|
|
218
|
+
// Filter out pull requests (REST issues endpoint returns both) and assigned issues
|
|
219
|
+
const issuesOnly = response.data.filter((item) => !("pull_request" in item) && !item.assignee);
|
|
220
|
+
const mapped = issuesOnly.map((issue) => ({
|
|
221
|
+
html_url: issue.html_url,
|
|
222
|
+
repository_url: `https://api.github.com/repos/${repoFullName}`,
|
|
223
|
+
updated_at: issue.updated_at ?? "",
|
|
224
|
+
title: issue.title,
|
|
225
|
+
labels: issue.labels,
|
|
226
|
+
}));
|
|
227
|
+
if (mapped.length > 0) {
|
|
228
|
+
const filtered = filterFn(mapped);
|
|
229
|
+
if (filtered.length > 0) {
|
|
230
|
+
const remainingNeeded = maxResults - candidates.length;
|
|
231
|
+
const { candidates: vetted, rateLimitHit: vetRateLimitHit } = await vetter.vetIssuesParallel(filtered
|
|
232
|
+
.slice(0, remainingNeeded * 2)
|
|
233
|
+
.map((item) => item.html_url), remainingNeeded, priority);
|
|
234
|
+
candidates.push(...vetted);
|
|
235
|
+
if (vetRateLimitHit)
|
|
236
|
+
rateLimitFailures++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (getHttpStatusCode(error) === 401)
|
|
242
|
+
throw error;
|
|
243
|
+
failedRepos++;
|
|
244
|
+
if (isRateLimitError(error)) {
|
|
245
|
+
rateLimitFailures++;
|
|
246
|
+
}
|
|
247
|
+
warn(MODULE, `Error fetching issues from ${repoFullName}:`, errorMessage(error));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const allReposFailed = failedRepos === repos.length && repos.length > 0;
|
|
251
|
+
const rateLimitHit = rateLimitFailures > 0;
|
|
252
|
+
if (allReposFailed) {
|
|
253
|
+
warn(MODULE, `All ${repos.length} repo(s) failed for ${priority} phase. ` +
|
|
254
|
+
`This may indicate a systemic issue (rate limit, auth, network).`);
|
|
255
|
+
}
|
|
256
|
+
return { candidates, allReposFailed, rateLimitHit };
|
|
257
|
+
}
|
|
112
258
|
/**
|
|
113
259
|
* Search across chunked labels with deduplication.
|
|
114
260
|
*
|
package/dist/core/types.d.ts
CHANGED
|
@@ -122,3 +122,10 @@ export interface ClosedPRRecord {
|
|
|
122
122
|
closedAt: string;
|
|
123
123
|
repo: string;
|
|
124
124
|
}
|
|
125
|
+
/** Record of an open PR for state contribution. */
|
|
126
|
+
export interface OpenPRRecord {
|
|
127
|
+
url: string;
|
|
128
|
+
title: string;
|
|
129
|
+
openedAt: string;
|
|
130
|
+
repo: string;
|
|
131
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* @packageDocumentation
|
|
16
16
|
*/
|
|
17
17
|
export { createScout, OssScout } from "./scout.js";
|
|
18
|
-
export type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, RepoScoreUpdate, ProjectHealth, SearchPriority, CheckResult, VetListOptions, VetListResult, VetListEntry, VetListSummary, } from "./core/types.js";
|
|
19
|
-
export type { ScoutState, ScoutPreferences, RepoScore, RepoSignals, IssueVettingResult, ContributionGuidelines, TrackedIssue, IssueScope, ProjectCategory, StoredMergedPR, StoredClosedPR, SearchStrategy, SkippedIssue, } from "./core/schemas.js";
|
|
18
|
+
export type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectHealth, SearchPriority, CheckResult, VetListOptions, VetListResult, VetListEntry, VetListSummary, } from "./core/types.js";
|
|
19
|
+
export type { ScoutState, ScoutPreferences, RepoScore, RepoSignals, IssueVettingResult, ContributionGuidelines, TrackedIssue, IssueScope, ProjectCategory, StoredMergedPR, StoredClosedPR, StoredOpenPR, SearchStrategy, SkippedIssue, } from "./core/schemas.js";
|
|
20
20
|
export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, } from "./core/schemas.js";
|
|
21
21
|
export { requireGitHubToken, getGitHubToken } from "./core/utils.js";
|
|
22
22
|
export { IssueDiscovery } from "./core/issue-discovery.js";
|
package/dist/scout.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ScoutStateReader } from "./core/issue-vetting.js";
|
|
8
8
|
import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue } from "./core/schemas.js";
|
|
9
|
-
import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from "./core/types.js";
|
|
9
|
+
import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from "./core/types.js";
|
|
10
10
|
import { GistStateStore } from "./core/gist-state-store.js";
|
|
11
11
|
/**
|
|
12
12
|
* Create an OssScout instance.
|
|
@@ -59,6 +59,7 @@ export declare class OssScout implements ScoutStateReader {
|
|
|
59
59
|
vetList(options?: VetListOptions): Promise<VetListResult>;
|
|
60
60
|
private classifyVetResult;
|
|
61
61
|
getReposWithMergedPRs(): string[];
|
|
62
|
+
getReposWithOpenPRs(): string[];
|
|
62
63
|
getStarredRepos(): string[];
|
|
63
64
|
getProjectCategories(): ProjectCategory[];
|
|
64
65
|
getRepoScore(repo: string): number | null;
|
|
@@ -75,6 +76,11 @@ export declare class OssScout implements ScoutStateReader {
|
|
|
75
76
|
* Record that a PR was closed without merge.
|
|
76
77
|
*/
|
|
77
78
|
recordClosedPR(pr: ClosedPRRecord): void;
|
|
79
|
+
/**
|
|
80
|
+
* Record that a PR is currently open in this repo.
|
|
81
|
+
* Open PRs signal active engagement even when nothing is merged yet.
|
|
82
|
+
*/
|
|
83
|
+
recordOpenPR(pr: OpenPRRecord): void;
|
|
78
84
|
/**
|
|
79
85
|
* Update repo score with observed signals.
|
|
80
86
|
*/
|
package/dist/scout.js
CHANGED
|
@@ -236,6 +236,18 @@ export class OssScout {
|
|
|
236
236
|
.sort((a, b) => b[1] - a[1])
|
|
237
237
|
.map(([repo]) => repo);
|
|
238
238
|
}
|
|
239
|
+
getReposWithOpenPRs() {
|
|
240
|
+
const repoCounts = new Map();
|
|
241
|
+
for (const pr of this.state.openPRs ?? []) {
|
|
242
|
+
const repo = extractRepoFromUrl(pr.url);
|
|
243
|
+
if (repo) {
|
|
244
|
+
repoCounts.set(repo, (repoCounts.get(repo) ?? 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return [...repoCounts.entries()]
|
|
248
|
+
.sort((a, b) => b[1] - a[1])
|
|
249
|
+
.map(([repo]) => repo);
|
|
250
|
+
}
|
|
239
251
|
getStarredRepos() {
|
|
240
252
|
return this.state.starredRepos;
|
|
241
253
|
}
|
|
@@ -285,6 +297,20 @@ export class OssScout {
|
|
|
285
297
|
this.updateRepoScoreFromPRs(pr.repo);
|
|
286
298
|
this.dirty = true;
|
|
287
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Record that a PR is currently open in this repo.
|
|
302
|
+
* Open PRs signal active engagement even when nothing is merged yet.
|
|
303
|
+
*/
|
|
304
|
+
recordOpenPR(pr) {
|
|
305
|
+
const existing = this.state.openPRs ?? [];
|
|
306
|
+
if (existing.some((p) => p.url === pr.url))
|
|
307
|
+
return;
|
|
308
|
+
this.state.openPRs = [
|
|
309
|
+
...existing,
|
|
310
|
+
{ url: pr.url, title: pr.title, openedAt: pr.openedAt },
|
|
311
|
+
];
|
|
312
|
+
this.dirty = true;
|
|
313
|
+
}
|
|
288
314
|
/**
|
|
289
315
|
* Update repo score with observed signals.
|
|
290
316
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oss-scout/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": {
|
|
@@ -21,16 +21,6 @@
|
|
|
21
21
|
"!dist/**/*.map",
|
|
22
22
|
"!dist/core/test-utils.*"
|
|
23
23
|
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"build": "tsc",
|
|
26
|
-
"bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --sourcemap --outfile=dist/cli.bundle.cjs",
|
|
27
|
-
"start": "tsx src/cli.ts",
|
|
28
|
-
"typecheck": "tsc --noEmit",
|
|
29
|
-
"test": "vitest run",
|
|
30
|
-
"test:coverage": "vitest run --coverage",
|
|
31
|
-
"test:watch": "vitest",
|
|
32
|
-
"prepublishOnly": "pnpm run build && pnpm run bundle"
|
|
33
|
-
},
|
|
34
24
|
"keywords": [
|
|
35
25
|
"open-source",
|
|
36
26
|
"github",
|
|
@@ -65,10 +55,20 @@
|
|
|
65
55
|
},
|
|
66
56
|
"devDependencies": {
|
|
67
57
|
"@types/node": "^25.5.0",
|
|
68
|
-
"@vitest/coverage-v8": "^4.1.
|
|
58
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
69
59
|
"esbuild": "^0.27.4",
|
|
70
60
|
"tsx": "^4.21.0",
|
|
71
61
|
"typescript": "^5.9.3",
|
|
72
|
-
"
|
|
62
|
+
"vite": "^8.0.5",
|
|
63
|
+
"vitest": "^4.1.4"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsc",
|
|
67
|
+
"bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --sourcemap --outfile=dist/cli.bundle.cjs",
|
|
68
|
+
"start": "tsx src/cli.ts",
|
|
69
|
+
"typecheck": "tsc --noEmit",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:coverage": "vitest run --coverage",
|
|
72
|
+
"test:watch": "vitest"
|
|
73
73
|
}
|
|
74
|
-
}
|
|
74
|
+
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runs a worker pool that processes items with bounded concurrency.
|
|
3
|
-
* N workers consume from a shared index. On any worker error, remaining
|
|
4
|
-
* workers are aborted via a shared flag and the error is propagated.
|
|
5
|
-
*/
|
|
6
|
-
export declare function runWorkerPool<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void>;
|
package/dist/core/concurrency.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runs a worker pool that processes items with bounded concurrency.
|
|
3
|
-
* N workers consume from a shared index. On any worker error, remaining
|
|
4
|
-
* workers are aborted via a shared flag and the error is propagated.
|
|
5
|
-
*/
|
|
6
|
-
export async function runWorkerPool(items, worker, concurrency) {
|
|
7
|
-
let index = 0;
|
|
8
|
-
let aborted = false;
|
|
9
|
-
const poolWorker = async () => {
|
|
10
|
-
while (index < items.length) {
|
|
11
|
-
if (aborted)
|
|
12
|
-
break;
|
|
13
|
-
const item = items[index++];
|
|
14
|
-
try {
|
|
15
|
-
await worker(item);
|
|
16
|
-
}
|
|
17
|
-
catch (err) {
|
|
18
|
-
aborted = true;
|
|
19
|
-
throw err;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
const workerCount = Math.min(concurrency, items.length);
|
|
24
|
-
await Promise.all(Array.from({ length: workerCount }, () => poolWorker()));
|
|
25
|
-
}
|