@oss-scout/core 0.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.
Files changed (66) hide show
  1. package/dist/cli.bundle.cjs +114 -0
  2. package/dist/cli.d.ts +5 -0
  3. package/dist/cli.js +341 -0
  4. package/dist/commands/config.d.ts +22 -0
  5. package/dist/commands/config.js +169 -0
  6. package/dist/commands/results.d.ts +8 -0
  7. package/dist/commands/results.js +13 -0
  8. package/dist/commands/search.d.ts +39 -0
  9. package/dist/commands/search.js +50 -0
  10. package/dist/commands/setup.d.ts +17 -0
  11. package/dist/commands/setup.js +104 -0
  12. package/dist/commands/validation.d.ts +6 -0
  13. package/dist/commands/validation.js +17 -0
  14. package/dist/commands/vet-list.d.ts +9 -0
  15. package/dist/commands/vet-list.js +16 -0
  16. package/dist/commands/vet.d.ts +25 -0
  17. package/dist/commands/vet.js +29 -0
  18. package/dist/core/bootstrap.d.ts +14 -0
  19. package/dist/core/bootstrap.js +122 -0
  20. package/dist/core/category-mapping.d.ts +19 -0
  21. package/dist/core/category-mapping.js +58 -0
  22. package/dist/core/concurrency.d.ts +6 -0
  23. package/dist/core/concurrency.js +25 -0
  24. package/dist/core/errors.d.ts +22 -0
  25. package/dist/core/errors.js +69 -0
  26. package/dist/core/gist-state-store.d.ts +96 -0
  27. package/dist/core/gist-state-store.js +302 -0
  28. package/dist/core/github.d.ts +16 -0
  29. package/dist/core/github.js +58 -0
  30. package/dist/core/http-cache.d.ts +108 -0
  31. package/dist/core/http-cache.js +314 -0
  32. package/dist/core/issue-discovery.d.ts +93 -0
  33. package/dist/core/issue-discovery.js +475 -0
  34. package/dist/core/issue-eligibility.d.ts +33 -0
  35. package/dist/core/issue-eligibility.js +151 -0
  36. package/dist/core/issue-filtering.d.ts +51 -0
  37. package/dist/core/issue-filtering.js +103 -0
  38. package/dist/core/issue-scoring.d.ts +43 -0
  39. package/dist/core/issue-scoring.js +97 -0
  40. package/dist/core/issue-vetting.d.ts +44 -0
  41. package/dist/core/issue-vetting.js +270 -0
  42. package/dist/core/local-state.d.ts +16 -0
  43. package/dist/core/local-state.js +56 -0
  44. package/dist/core/logger.d.ts +11 -0
  45. package/dist/core/logger.js +25 -0
  46. package/dist/core/pagination.d.ts +7 -0
  47. package/dist/core/pagination.js +16 -0
  48. package/dist/core/repo-health.d.ts +19 -0
  49. package/dist/core/repo-health.js +179 -0
  50. package/dist/core/schemas.d.ts +315 -0
  51. package/dist/core/schemas.js +137 -0
  52. package/dist/core/search-budget.d.ts +62 -0
  53. package/dist/core/search-budget.js +129 -0
  54. package/dist/core/search-phases.d.ts +69 -0
  55. package/dist/core/search-phases.js +238 -0
  56. package/dist/core/types.d.ts +124 -0
  57. package/dist/core/types.js +9 -0
  58. package/dist/core/utils.d.ts +18 -0
  59. package/dist/core/utils.js +106 -0
  60. package/dist/formatters/json.d.ts +6 -0
  61. package/dist/formatters/json.js +20 -0
  62. package/dist/index.d.ts +23 -0
  63. package/dist/index.js +25 -0
  64. package/dist/scout.d.ts +125 -0
  65. package/dist/scout.js +391 -0
  66. package/package.json +70 -0
package/dist/scout.js ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * OssScout — the public API for oss-scout.
3
+ *
4
+ * Provides personalized issue discovery, vetting, and scoring.
5
+ * Implements ScoutStateReader to bridge state with the search engine.
6
+ */
7
+ import { IssueDiscovery } from './core/issue-discovery.js';
8
+ import { ScoutStateSchema } from './core/schemas.js';
9
+ import { GistStateStore, mergeStates } from './core/gist-state-store.js';
10
+ import { getOctokit } from './core/github.js';
11
+ import { loadLocalState } from './core/local-state.js';
12
+ import { warn } from './core/logger.js';
13
+ /**
14
+ * Create an OssScout instance.
15
+ *
16
+ * @param config - Configuration including GitHub token and persistence mode
17
+ * @returns A ready-to-use OssScout instance
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { createScout } from '@oss-scout/core';
22
+ *
23
+ * // Standalone with gist persistence
24
+ * const scout = await createScout({ githubToken: 'ghp_...', persistence: 'gist' });
25
+ *
26
+ * // As a library (host application provides state)
27
+ * const scout = await createScout({
28
+ * githubToken: 'ghp_...',
29
+ * persistence: 'provided',
30
+ * initialState: myState,
31
+ * });
32
+ * ```
33
+ */
34
+ export async function createScout(config) {
35
+ let state;
36
+ let gistStore = null;
37
+ if (config.persistence === 'provided') {
38
+ state = config.initialState;
39
+ }
40
+ else if (config.persistence === 'gist') {
41
+ gistStore = new GistStateStore(getOctokit(config.githubToken));
42
+ const result = await gistStore.bootstrap();
43
+ if (result.degraded) {
44
+ warn('scout', 'Gist sync unavailable — running in offline mode. Changes will only be saved locally.');
45
+ }
46
+ const localState = loadLocalState();
47
+ state = mergeStates(localState, result.state);
48
+ if (config.gistId) {
49
+ state.gistId = config.gistId;
50
+ }
51
+ else if (result.gistId) {
52
+ state.gistId = result.gistId;
53
+ }
54
+ }
55
+ else {
56
+ state = ScoutStateSchema.parse({ version: 1 });
57
+ }
58
+ return new OssScout(config.githubToken, state, gistStore);
59
+ }
60
+ /**
61
+ * Main oss-scout class. Provides search, vetting, and state management.
62
+ *
63
+ * Implements ScoutStateReader so the search engine can read state
64
+ * without knowing about the persistence layer.
65
+ */
66
+ export class OssScout {
67
+ githubToken;
68
+ gistStore;
69
+ state;
70
+ dirty = false;
71
+ constructor(githubToken, initialState, gistStore = null) {
72
+ this.githubToken = githubToken;
73
+ this.gistStore = gistStore;
74
+ this.state = initialState;
75
+ }
76
+ // ── Search ──────────────────────────────────────────────────────────
77
+ /**
78
+ * Multi-strategy issue search. Returns scored, sorted candidates.
79
+ */
80
+ async search(options) {
81
+ const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
82
+ const { candidates, strategiesUsed } = await discovery.searchIssues({
83
+ maxResults: options?.maxResults,
84
+ strategies: options?.strategies,
85
+ });
86
+ this.state.lastSearchAt = new Date().toISOString();
87
+ this.dirty = true;
88
+ return {
89
+ candidates,
90
+ excludedRepos: this.state.preferences.excludeRepos,
91
+ aiPolicyBlocklist: this.state.preferences.aiPolicyBlocklist,
92
+ rateLimitWarning: discovery.rateLimitWarning ?? undefined,
93
+ strategiesUsed,
94
+ };
95
+ }
96
+ /**
97
+ * Vet a single issue URL for claimability.
98
+ */
99
+ async vetIssue(issueUrl) {
100
+ const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
101
+ return discovery.vetIssue(issueUrl);
102
+ }
103
+ // ── Batch Vetting ───────────────────────────────────────────────────
104
+ /**
105
+ * Re-vet all saved results with bounded concurrency.
106
+ * Classifies each as still_available, claimed, has_pr, closed, or error.
107
+ * Optionally prunes unavailable issues from saved results.
108
+ */
109
+ async vetList(options) {
110
+ const saved = this.getSavedResults();
111
+ const concurrency = options?.concurrency ?? 5;
112
+ const results = [];
113
+ const pending = new Map();
114
+ for (const item of saved) {
115
+ const task = this.vetIssue(item.issueUrl)
116
+ .then((candidate) => {
117
+ results.push({
118
+ issueUrl: item.issueUrl,
119
+ repo: item.repo,
120
+ number: item.number,
121
+ title: item.title,
122
+ status: this.classifyVetResult(candidate),
123
+ recommendation: candidate.recommendation,
124
+ viabilityScore: candidate.viabilityScore,
125
+ });
126
+ })
127
+ .catch((error) => {
128
+ const msg = error instanceof Error ? error.message : String(error);
129
+ const isGone = msg.includes('Not Found') || msg.includes('410');
130
+ results.push({
131
+ issueUrl: item.issueUrl,
132
+ repo: item.repo,
133
+ number: item.number,
134
+ title: item.title,
135
+ status: isGone ? 'closed' : 'error',
136
+ errorMessage: msg,
137
+ });
138
+ })
139
+ .finally(() => {
140
+ pending.delete(item.issueUrl);
141
+ });
142
+ pending.set(item.issueUrl, task);
143
+ if (pending.size >= concurrency) {
144
+ await Promise.race(pending.values());
145
+ }
146
+ }
147
+ await Promise.allSettled(pending.values());
148
+ const summary = {
149
+ total: results.length,
150
+ stillAvailable: results.filter((r) => r.status === 'still_available').length,
151
+ claimed: results.filter((r) => r.status === 'claimed').length,
152
+ closed: results.filter((r) => r.status === 'closed').length,
153
+ hasPR: results.filter((r) => r.status === 'has_pr').length,
154
+ errors: results.filter((r) => r.status === 'error').length,
155
+ };
156
+ let prunedCount;
157
+ if (options?.prune) {
158
+ const unavailableUrls = new Set(results.filter((r) => r.status !== 'still_available').map((r) => r.issueUrl));
159
+ const before = (this.state.savedResults ?? []).length;
160
+ this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
161
+ prunedCount = before - (this.state.savedResults?.length ?? 0);
162
+ this.dirty = true;
163
+ }
164
+ return { results, summary, prunedCount };
165
+ }
166
+ classifyVetResult(candidate) {
167
+ const checks = candidate.vettingResult.checks;
168
+ if (!checks.noExistingPR)
169
+ return 'has_pr';
170
+ if (!checks.notClaimed)
171
+ return 'claimed';
172
+ return 'still_available';
173
+ }
174
+ // ── State Reads (ScoutStateReader implementation) ───────────────────
175
+ getReposWithMergedPRs() {
176
+ const repoCounts = new Map();
177
+ for (const pr of this.state.mergedPRs ?? []) {
178
+ const repo = this.extractRepoFromUrl(pr.url);
179
+ if (repo) {
180
+ repoCounts.set(repo, (repoCounts.get(repo) ?? 0) + 1);
181
+ }
182
+ }
183
+ // Sort by count descending
184
+ return [...repoCounts.entries()]
185
+ .sort((a, b) => b[1] - a[1])
186
+ .map(([repo]) => repo);
187
+ }
188
+ getStarredRepos() {
189
+ return this.state.starredRepos;
190
+ }
191
+ getPreferredOrgs() {
192
+ return this.state.preferences.preferredOrgs;
193
+ }
194
+ getProjectCategories() {
195
+ return this.state.preferences.projectCategories;
196
+ }
197
+ getRepoScore(repo) {
198
+ const score = this.state.repoScores[repo];
199
+ return score ? score.score : null;
200
+ }
201
+ /** Get current preferences (read-only). */
202
+ getPreferences() {
203
+ return this.state.preferences;
204
+ }
205
+ /** Get repo score record for a specific repository. */
206
+ getRepoScoreRecord(repo) {
207
+ return this.state.repoScores[repo];
208
+ }
209
+ // ── State Mutations ─────────────────────────────────────────────────
210
+ /**
211
+ * Record that a PR was merged in this repo.
212
+ * Updates the merged PRs list and recalculates the repo score.
213
+ */
214
+ recordMergedPR(pr) {
215
+ const existing = this.state.mergedPRs ?? [];
216
+ // Deduplicate by URL
217
+ if (existing.some((p) => p.url === pr.url))
218
+ return;
219
+ this.state.mergedPRs = [
220
+ ...existing,
221
+ { url: pr.url, title: pr.title, mergedAt: pr.mergedAt },
222
+ ];
223
+ this.updateRepoScoreFromPRs(pr.repo);
224
+ this.dirty = true;
225
+ }
226
+ /**
227
+ * Record that a PR was closed without merge.
228
+ */
229
+ recordClosedPR(pr) {
230
+ const existing = this.state.closedPRs ?? [];
231
+ if (existing.some((p) => p.url === pr.url))
232
+ return;
233
+ this.state.closedPRs = [
234
+ ...existing,
235
+ { url: pr.url, title: pr.title, closedAt: pr.closedAt },
236
+ ];
237
+ this.updateRepoScoreFromPRs(pr.repo);
238
+ this.dirty = true;
239
+ }
240
+ /**
241
+ * Update repo score with observed signals.
242
+ */
243
+ updateRepoScore(repo, update) {
244
+ const existing = this.state.repoScores[repo];
245
+ const base = existing ?? {
246
+ repo,
247
+ score: 5,
248
+ mergedPRCount: 0,
249
+ closedWithoutMergeCount: 0,
250
+ avgResponseDays: null,
251
+ lastEvaluatedAt: new Date().toISOString(),
252
+ signals: {
253
+ hasActiveMaintainers: false,
254
+ isResponsive: false,
255
+ hasHostileComments: false,
256
+ },
257
+ };
258
+ const updated = {
259
+ ...base,
260
+ ...update,
261
+ repo,
262
+ lastEvaluatedAt: new Date().toISOString(),
263
+ signals: { ...base.signals, ...(update.signals ?? {}) },
264
+ };
265
+ // Recalculate score
266
+ updated.score = this.calculateScore(updated);
267
+ this.state.repoScores[repo] = updated;
268
+ this.dirty = true;
269
+ }
270
+ /**
271
+ * Update user preferences.
272
+ */
273
+ updatePreferences(updates) {
274
+ this.state.preferences = { ...this.state.preferences, ...updates };
275
+ this.dirty = true;
276
+ }
277
+ /**
278
+ * Update starred repos cache.
279
+ */
280
+ setStarredRepos(repos) {
281
+ this.state.starredRepos = repos;
282
+ this.state.starredReposLastFetched = new Date().toISOString();
283
+ this.dirty = true;
284
+ }
285
+ // ── Saved Results ───────────────────────────────────────────────────
286
+ /**
287
+ * Save search candidates to state, deduplicating by URL.
288
+ * If a candidate already exists, updates score/recommendation/lastSeenAt
289
+ * but preserves firstSeenAt.
290
+ */
291
+ saveResults(candidates) {
292
+ const now = new Date().toISOString();
293
+ const existing = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r]));
294
+ for (const c of candidates) {
295
+ const prev = existing.get(c.issue.url);
296
+ existing.set(c.issue.url, {
297
+ issueUrl: c.issue.url,
298
+ repo: c.issue.repo,
299
+ number: c.issue.number,
300
+ title: c.issue.title,
301
+ labels: c.issue.labels,
302
+ recommendation: c.recommendation,
303
+ viabilityScore: c.viabilityScore,
304
+ searchPriority: c.searchPriority,
305
+ firstSeenAt: prev?.firstSeenAt ?? now,
306
+ lastSeenAt: now,
307
+ lastScore: c.viabilityScore,
308
+ });
309
+ }
310
+ this.state.savedResults = [...existing.values()];
311
+ this.dirty = true;
312
+ }
313
+ /**
314
+ * Get all saved results.
315
+ */
316
+ getSavedResults() {
317
+ return this.state.savedResults ?? [];
318
+ }
319
+ /**
320
+ * Clear all saved results.
321
+ */
322
+ clearResults() {
323
+ this.state.savedResults = [];
324
+ this.dirty = true;
325
+ }
326
+ // ── Persistence ─────────────────────────────────────────────────────
327
+ /**
328
+ * Check if state has uncommitted changes.
329
+ */
330
+ isDirty() {
331
+ return this.dirty;
332
+ }
333
+ /**
334
+ * Push pending changes to the persistence layer.
335
+ * Pushes to gist if gist persistence is configured.
336
+ */
337
+ async checkpoint() {
338
+ if (!this.dirty)
339
+ return true;
340
+ this.state.lastRunAt = new Date().toISOString();
341
+ if (this.gistStore) {
342
+ const ok = await this.gistStore.push(this.state);
343
+ if (!ok)
344
+ return false;
345
+ }
346
+ this.dirty = false;
347
+ return true;
348
+ }
349
+ /**
350
+ * Get the full state snapshot for serialization or external consumption.
351
+ */
352
+ getState() {
353
+ return this.state;
354
+ }
355
+ // ── Private helpers ─────────────────────────────────────────────────
356
+ extractRepoFromUrl(url) {
357
+ const match = url.match(/github\.com\/([^/]+\/[^/]+)\//);
358
+ return match ? match[1] : null;
359
+ }
360
+ updateRepoScoreFromPRs(repo) {
361
+ const mergedCount = (this.state.mergedPRs ?? []).filter((p) => this.extractRepoFromUrl(p.url) === repo).length;
362
+ const closedCount = (this.state.closedPRs ?? []).filter((p) => this.extractRepoFromUrl(p.url) === repo).length;
363
+ this.updateRepoScore(repo, {
364
+ mergedPRCount: mergedCount,
365
+ closedWithoutMergeCount: closedCount,
366
+ lastMergedAt: mergedCount > 0
367
+ ? (this.state.mergedPRs ?? [])
368
+ .filter((p) => this.extractRepoFromUrl(p.url) === repo)
369
+ .sort((a, b) => b.mergedAt.localeCompare(a.mergedAt))[0]
370
+ ?.mergedAt
371
+ : undefined,
372
+ });
373
+ }
374
+ /**
375
+ * Calculate repo score (1-10) from observed data.
376
+ * base 5, +1 per merged PR (max +3), -1 per closed-without-merge (max -3),
377
+ * +1 responsive, +1 active maintainers, -2 hostile comments, clamped 1-10
378
+ */
379
+ calculateScore(repoScore) {
380
+ let score = 5;
381
+ score += Math.min(repoScore.mergedPRCount, 3);
382
+ score -= Math.min(repoScore.closedWithoutMergeCount, 3);
383
+ if (repoScore.signals.isResponsive)
384
+ score += 1;
385
+ if (repoScore.signals.hasActiveMaintainers)
386
+ score += 1;
387
+ if (repoScore.signals.hasHostileComments)
388
+ score -= 2;
389
+ return Math.max(1, Math.min(10, score));
390
+ }
391
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@oss-scout/core",
3
+ "version": "0.1.0",
4
+ "description": "Find open source issues personalized to your contribution history",
5
+ "type": "module",
6
+ "bin": {
7
+ "oss-scout": "./dist/cli.bundle.cjs"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./types": {
15
+ "import": "./dist/core/types.js",
16
+ "types": "./dist/core/types.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist/",
21
+ "!dist/**/*.map",
22
+ "!dist/core/test-utils.*"
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
+ "keywords": [
35
+ "open-source",
36
+ "github",
37
+ "issue-discovery",
38
+ "cli",
39
+ "vetting",
40
+ "contributions"
41
+ ],
42
+ "author": "John Costa",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/costajohnt/oss-scout.git",
47
+ "directory": "packages/core"
48
+ },
49
+ "homepage": "https://github.com/costajohnt/oss-scout#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/costajohnt/oss-scout/issues"
52
+ },
53
+ "engines": {
54
+ "node": ">=20.0.0"
55
+ },
56
+ "dependencies": {
57
+ "@octokit/plugin-throttling": "^11.0.3",
58
+ "@octokit/rest": "^22.0.1",
59
+ "commander": "^14.0.3",
60
+ "zod": "^4.3.6"
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^25.5.0",
64
+ "@vitest/coverage-v8": "^4.1.0",
65
+ "esbuild": "^0.27.4",
66
+ "tsx": "^4.21.0",
67
+ "typescript": "^5.9.3",
68
+ "vitest": "^4.1.0"
69
+ }
70
+ }