@oss-scout/core 0.2.0 → 0.3.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 (56) hide show
  1. package/dist/cli.bundle.cjs +51 -47
  2. package/dist/cli.js +218 -87
  3. package/dist/commands/config.d.ts +2 -4
  4. package/dist/commands/config.js +76 -78
  5. package/dist/commands/results.d.ts +1 -1
  6. package/dist/commands/results.js +1 -1
  7. package/dist/commands/search.d.ts +2 -2
  8. package/dist/commands/search.js +16 -6
  9. package/dist/commands/setup.d.ts +1 -1
  10. package/dist/commands/setup.js +25 -25
  11. package/dist/commands/skip.d.ts +33 -0
  12. package/dist/commands/skip.js +89 -0
  13. package/dist/commands/validation.d.ts +1 -1
  14. package/dist/commands/validation.js +1 -1
  15. package/dist/commands/vet-list.d.ts +2 -2
  16. package/dist/commands/vet-list.js +12 -5
  17. package/dist/commands/vet.d.ts +3 -3
  18. package/dist/commands/vet.js +9 -5
  19. package/dist/core/bootstrap.d.ts +1 -1
  20. package/dist/core/bootstrap.js +20 -16
  21. package/dist/core/category-mapping.d.ts +1 -1
  22. package/dist/core/category-mapping.js +104 -13
  23. package/dist/core/errors.d.ts +8 -1
  24. package/dist/core/errors.js +31 -19
  25. package/dist/core/gist-state-store.d.ts +1 -1
  26. package/dist/core/gist-state-store.js +55 -28
  27. package/dist/core/github.d.ts +1 -1
  28. package/dist/core/github.js +5 -5
  29. package/dist/core/http-cache.js +26 -22
  30. package/dist/core/issue-discovery.d.ts +6 -6
  31. package/dist/core/issue-discovery.js +279 -286
  32. package/dist/core/issue-eligibility.d.ts +2 -2
  33. package/dist/core/issue-eligibility.js +26 -21
  34. package/dist/core/issue-filtering.js +23 -15
  35. package/dist/core/issue-scoring.js +1 -1
  36. package/dist/core/issue-vetting.d.ts +2 -4
  37. package/dist/core/issue-vetting.js +65 -56
  38. package/dist/core/local-state.d.ts +1 -1
  39. package/dist/core/local-state.js +16 -14
  40. package/dist/core/repo-health.d.ts +2 -2
  41. package/dist/core/repo-health.js +46 -35
  42. package/dist/core/schemas.d.ts +17 -9
  43. package/dist/core/schemas.js +47 -19
  44. package/dist/core/search-budget.js +3 -3
  45. package/dist/core/search-phases.d.ts +6 -6
  46. package/dist/core/search-phases.js +23 -19
  47. package/dist/core/types.d.ts +9 -9
  48. package/dist/core/types.js +15 -3
  49. package/dist/core/utils.d.ts +10 -1
  50. package/dist/core/utils.js +44 -25
  51. package/dist/formatters/json.d.ts +1 -1
  52. package/dist/index.d.ts +7 -7
  53. package/dist/index.js +5 -5
  54. package/dist/scout.d.ts +30 -6
  55. package/dist/scout.js +141 -34
  56. package/package.json +7 -3
@@ -1,39 +1,58 @@
1
1
  /**
2
2
  * Shared utility functions for oss-scout.
3
3
  */
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
- import * as os from 'os';
7
- import { execFileSync } from 'child_process';
8
- import { ConfigurationError, errorMessage } from './errors.js';
9
- import { debug } from './logger.js';
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import { execFileSync } from "child_process";
8
+ import { ConfigurationError, errorMessage } from "./errors.js";
9
+ import { debug } from "./logger.js";
10
10
  export function sleep(ms) {
11
11
  return new Promise((resolve) => setTimeout(resolve, ms));
12
12
  }
13
- const MODULE = 'utils';
13
+ const MODULE = "utils";
14
14
  let cachedGitHubToken = null;
15
15
  let tokenFetchAttempted = false;
16
16
  export function getDataDir() {
17
- const dir = path.join(os.homedir(), '.oss-scout');
17
+ const dir = path.join(os.homedir(), ".oss-scout");
18
18
  if (!fs.existsSync(dir)) {
19
19
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
20
20
  }
21
21
  return dir;
22
22
  }
23
23
  export function getCacheDir() {
24
- const dir = path.join(getDataDir(), 'cache');
24
+ const dir = path.join(getDataDir(), "cache");
25
25
  if (!fs.existsSync(dir)) {
26
26
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
27
27
  }
28
28
  return dir;
29
29
  }
30
+ /**
31
+ * Extract "owner/repo" from any GitHub URL format:
32
+ * - https://github.com/owner/repo
33
+ * - https://github.com/owner/repo/pull/123
34
+ * - https://github.com/owner/repo/issues/123
35
+ * - https://api.github.com/repos/owner/repo
36
+ * - https://api.github.com/repos/owner/repo/...
37
+ */
38
+ export function extractRepoFromUrl(url) {
39
+ // API URLs: https://api.github.com/repos/owner/repo[/...]
40
+ const apiMatch = url.match(/api\.github\.com\/repos\/([^/]+\/[^/]+)/);
41
+ if (apiMatch)
42
+ return apiMatch[1];
43
+ // Web URLs: https://github.com/owner/repo[/...]
44
+ const webMatch = url.match(/github\.com\/([^/]+\/[^/]+)/);
45
+ if (webMatch)
46
+ return webMatch[1];
47
+ return null;
48
+ }
30
49
  const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
31
50
  const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
32
51
  function isValidOwnerRepo(owner, repo) {
33
52
  return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
34
53
  }
35
54
  export function parseGitHubUrl(url) {
36
- if (!url.startsWith('https://github.com/'))
55
+ if (!url.startsWith("https://github.com/"))
37
56
  return null;
38
57
  const prMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
39
58
  if (prMatch) {
@@ -41,7 +60,7 @@ export function parseGitHubUrl(url) {
41
60
  const repo = prMatch[2];
42
61
  if (!isValidOwnerRepo(owner, repo))
43
62
  return null;
44
- return { owner, repo, number: parseInt(prMatch[3], 10), type: 'pull' };
63
+ return { owner, repo, number: parseInt(prMatch[3], 10), type: "pull" };
45
64
  }
46
65
  const issueMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
47
66
  if (issueMatch) {
@@ -49,7 +68,7 @@ export function parseGitHubUrl(url) {
49
68
  const repo = issueMatch[2];
50
69
  if (!isValidOwnerRepo(owner, repo))
51
70
  return null;
52
- return { owner, repo, number: parseInt(issueMatch[3], 10), type: 'issues' };
71
+ return { owner, repo, number: parseInt(issueMatch[3], 10), type: "issues" };
53
72
  }
54
73
  return null;
55
74
  }
@@ -58,12 +77,12 @@ export function daysBetween(from, to = new Date()) {
58
77
  }
59
78
  export function getCLIVersion() {
60
79
  try {
61
- const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
62
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
80
+ const pkgPath = path.join(path.dirname(process.argv[1]), "..", "package.json");
81
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version;
63
82
  }
64
83
  catch (err) {
65
84
  debug(MODULE, `Could not read CLI version: ${errorMessage(err)}`);
66
- return 'unknown';
85
+ return "unknown";
67
86
  }
68
87
  }
69
88
  export function getGitHubToken() {
@@ -77,30 +96,30 @@ export function getGitHubToken() {
77
96
  return cachedGitHubToken;
78
97
  }
79
98
  try {
80
- const token = execFileSync('gh', ['auth', 'token'], {
81
- encoding: 'utf-8',
82
- stdio: ['pipe', 'pipe', 'pipe'],
99
+ const token = execFileSync("gh", ["auth", "token"], {
100
+ encoding: "utf-8",
101
+ stdio: ["pipe", "pipe", "pipe"],
83
102
  timeout: 2000,
84
103
  }).trim();
85
104
  if (token && token.length > 0) {
86
105
  cachedGitHubToken = token;
87
- debug(MODULE, 'Using GitHub token from gh CLI');
106
+ debug(MODULE, "Using GitHub token from gh CLI");
88
107
  return cachedGitHubToken;
89
108
  }
90
109
  }
91
110
  catch (err) {
92
- debug(MODULE, 'gh auth token failed', err);
111
+ debug(MODULE, "gh auth token failed", err);
93
112
  }
94
113
  return null;
95
114
  }
96
115
  export function requireGitHubToken() {
97
116
  const token = getGitHubToken();
98
117
  if (!token) {
99
- throw new ConfigurationError('GitHub authentication required.\n\n' +
100
- 'Options:\n' +
101
- ' 1. Use gh CLI: gh auth login\n' +
102
- ' 2. Set GITHUB_TOKEN environment variable\n\n' +
103
- 'The gh CLI is recommended - install from https://cli.github.com');
118
+ throw new ConfigurationError("GitHub authentication required.\n\n" +
119
+ "Options:\n" +
120
+ " 1. Use gh CLI: gh auth login\n" +
121
+ " 2. Set GITHUB_TOKEN environment variable\n\n" +
122
+ "The gh CLI is recommended - install from https://cli.github.com");
104
123
  }
105
124
  return token;
106
125
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * JSON output formatter for oss-scout CLI.
3
3
  */
4
- import type { ErrorCode } from '../core/errors.js';
4
+ import type { ErrorCode } from "../core/errors.js";
5
5
  export declare function formatJsonSuccess<T>(data: T): string;
6
6
  export declare function formatJsonError(error: string, errorCode?: ErrorCode): string;
package/dist/index.d.ts CHANGED
@@ -14,10 +14,10 @@
14
14
  *
15
15
  * @packageDocumentation
16
16
  */
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, } from './core/schemas.js';
20
- export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, } from './core/schemas.js';
21
- export { requireGitHubToken, getGitHubToken } from './core/utils.js';
22
- export { IssueDiscovery } from './core/issue-discovery.js';
23
- export { IssueVetter, type ScoutStateReader } from './core/issue-vetting.js';
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";
20
+ export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, } from "./core/schemas.js";
21
+ export { requireGitHubToken, getGitHubToken } from "./core/utils.js";
22
+ export { IssueDiscovery } from "./core/issue-discovery.js";
23
+ export { IssueVetter, type ScoutStateReader } from "./core/issue-vetting.js";
package/dist/index.js CHANGED
@@ -15,11 +15,11 @@
15
15
  * @packageDocumentation
16
16
  */
17
17
  // Main API
18
- export { createScout, OssScout } from './scout.js';
18
+ export { createScout, OssScout } from "./scout.js";
19
19
  // Schemas (for consumers who need runtime validation)
20
- export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, } from './core/schemas.js';
20
+ export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, } from "./core/schemas.js";
21
21
  // Utilities
22
- export { requireGitHubToken, getGitHubToken } from './core/utils.js';
22
+ export { requireGitHubToken, getGitHubToken } from "./core/utils.js";
23
23
  // Internal classes (for advanced use)
24
- export { IssueDiscovery } from './core/issue-discovery.js';
25
- export { IssueVetter } from './core/issue-vetting.js';
24
+ export { IssueDiscovery } from "./core/issue-discovery.js";
25
+ export { IssueVetter } from "./core/issue-vetting.js";
package/dist/scout.d.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  * Provides personalized issue discovery, vetting, and scoring.
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
6
  */
7
- import type { ScoutStateReader } from './core/issue-vetting.js';
8
- import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate } from './core/schemas.js';
9
- import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from './core/types.js';
10
- import { GistStateStore } from './core/gist-state-store.js';
7
+ import type { ScoutStateReader } from "./core/issue-vetting.js";
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";
10
+ import { GistStateStore } from "./core/gist-state-store.js";
11
11
  /**
12
12
  * Create an OssScout instance.
13
13
  *
@@ -44,6 +44,7 @@ export declare class OssScout implements ScoutStateReader {
44
44
  constructor(githubToken: string, initialState: ScoutState, gistStore?: GistStateStore | null);
45
45
  /**
46
46
  * Multi-strategy issue search. Returns scored, sorted candidates.
47
+ * Automatically culls expired skip entries and filters skipped issues.
47
48
  */
48
49
  search(options?: SearchOptions): Promise<SearchResult>;
49
50
  /**
@@ -59,7 +60,6 @@ export declare class OssScout implements ScoutStateReader {
59
60
  private classifyVetResult;
60
61
  getReposWithMergedPRs(): string[];
61
62
  getStarredRepos(): string[];
62
- getPreferredOrgs(): string[];
63
63
  getProjectCategories(): ProjectCategory[];
64
64
  getRepoScore(repo: string): number | null;
65
65
  /** Get current preferences (read-only). */
@@ -101,6 +101,31 @@ export declare class OssScout implements ScoutStateReader {
101
101
  * Clear all saved results.
102
102
  */
103
103
  clearResults(): void;
104
+ /**
105
+ * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
106
+ */
107
+ skipIssue(url: string, metadata?: {
108
+ repo?: string;
109
+ number?: number;
110
+ title?: string;
111
+ }): void;
112
+ /**
113
+ * Get all skipped issues.
114
+ */
115
+ getSkippedIssues(): SkippedIssue[];
116
+ /**
117
+ * Remove a specific issue from the skip list.
118
+ */
119
+ unskipIssue(url: string): void;
120
+ /**
121
+ * Clear all skipped issues.
122
+ */
123
+ clearSkippedIssues(): void;
124
+ /**
125
+ * Remove skipped issues older than maxDays (default 90). Called automatically during search.
126
+ * @returns The number of expired entries that were removed.
127
+ */
128
+ cullExpiredSkips(maxDays?: number): number;
104
129
  /**
105
130
  * Check if state has uncommitted changes.
106
131
  */
@@ -114,7 +139,6 @@ export declare class OssScout implements ScoutStateReader {
114
139
  * Get the full state snapshot for serialization or external consumption.
115
140
  */
116
141
  getState(): Readonly<ScoutState>;
117
- private extractRepoFromUrl;
118
142
  private updateRepoScoreFromPRs;
119
143
  /**
120
144
  * Calculate repo score (1-10) from observed data.
package/dist/scout.js CHANGED
@@ -4,12 +4,55 @@
4
4
  * Provides personalized issue discovery, vetting, and scoring.
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
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';
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
+ import { extractRepoFromUrl } from "./core/utils.js";
14
+ /** Wrap a real Octokit instance as GistOctokitLike without unsafe double casts. */
15
+ function toGistOctokit(octokit) {
16
+ return {
17
+ gists: {
18
+ async get(params) {
19
+ const { data } = await octokit.gists.get(params);
20
+ if (!data.id)
21
+ throw new Error("Gist get returned no id");
22
+ const files = data.files
23
+ ? Object.fromEntries(Object.entries(data.files).map(([k, v]) => [
24
+ k,
25
+ v ? { content: v.content } : undefined,
26
+ ]))
27
+ : null;
28
+ return { data: { id: data.id, files } };
29
+ },
30
+ async create(params) {
31
+ const { data } = await octokit.gists.create(params);
32
+ if (!data.id)
33
+ throw new Error("Gist create returned no id");
34
+ return { data: { id: data.id } };
35
+ },
36
+ async update(params) {
37
+ const { data } = await octokit.gists.update(params);
38
+ if (!data.id)
39
+ throw new Error("Gist update returned no id");
40
+ return { data: { id: data.id } };
41
+ },
42
+ async list(params) {
43
+ const { data } = await octokit.gists.list(params);
44
+ return {
45
+ data: data
46
+ .filter((g) => g.id)
47
+ .map((g) => ({
48
+ id: g.id,
49
+ description: g.description ?? null,
50
+ })),
51
+ };
52
+ },
53
+ },
54
+ };
55
+ }
13
56
  /**
14
57
  * Create an OssScout instance.
15
58
  *
@@ -34,14 +77,14 @@ import { warn } from './core/logger.js';
34
77
  export async function createScout(config) {
35
78
  let state;
36
79
  let gistStore = null;
37
- if (config.persistence === 'provided') {
80
+ if (config.persistence === "provided") {
38
81
  state = config.initialState;
39
82
  }
40
- else if (config.persistence === 'gist') {
41
- gistStore = new GistStateStore(getOctokit(config.githubToken));
83
+ else if (config.persistence === "gist") {
84
+ gistStore = new GistStateStore(toGistOctokit(getOctokit(config.githubToken)));
42
85
  const result = await gistStore.bootstrap();
43
86
  if (result.degraded) {
44
- warn('scout', 'Gist sync unavailable — running in offline mode. Changes will only be saved locally.');
87
+ warn("scout", "Gist sync unavailable — running in offline mode. Changes will only be saved locally.");
45
88
  }
46
89
  const localState = loadLocalState();
47
90
  state = mergeStates(localState, result.state);
@@ -76,12 +119,17 @@ export class OssScout {
76
119
  // ── Search ──────────────────────────────────────────────────────────
77
120
  /**
78
121
  * Multi-strategy issue search. Returns scored, sorted candidates.
122
+ * Automatically culls expired skip entries and filters skipped issues.
79
123
  */
80
124
  async search(options) {
125
+ // Auto-cull expired skips before searching
126
+ this.cullExpiredSkips();
127
+ const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
81
128
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
82
129
  const { candidates, strategiesUsed } = await discovery.searchIssues({
83
130
  maxResults: options?.maxResults,
84
131
  strategies: options?.strategies,
132
+ skippedUrls,
85
133
  });
86
134
  this.state.lastSearchAt = new Date().toISOString();
87
135
  this.dirty = true;
@@ -126,13 +174,13 @@ export class OssScout {
126
174
  })
127
175
  .catch((error) => {
128
176
  const msg = error instanceof Error ? error.message : String(error);
129
- const isGone = msg.includes('Not Found') || msg.includes('410');
177
+ const isGone = msg.includes("Not Found") || msg.includes("410");
130
178
  results.push({
131
179
  issueUrl: item.issueUrl,
132
180
  repo: item.repo,
133
181
  number: item.number,
134
182
  title: item.title,
135
- status: isGone ? 'closed' : 'error',
183
+ status: isGone ? "closed" : "error",
136
184
  errorMessage: msg,
137
185
  });
138
186
  })
@@ -147,15 +195,18 @@ export class OssScout {
147
195
  await Promise.allSettled(pending.values());
148
196
  const summary = {
149
197
  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,
198
+ stillAvailable: results.filter((r) => r.status === "still_available")
199
+ .length,
200
+ claimed: results.filter((r) => r.status === "claimed").length,
201
+ closed: results.filter((r) => r.status === "closed").length,
202
+ hasPR: results.filter((r) => r.status === "has_pr").length,
203
+ errors: results.filter((r) => r.status === "error").length,
155
204
  };
156
205
  let prunedCount;
157
206
  if (options?.prune) {
158
- const unavailableUrls = new Set(results.filter((r) => r.status !== 'still_available').map((r) => r.issueUrl));
207
+ const unavailableUrls = new Set(results
208
+ .filter((r) => r.status !== "still_available")
209
+ .map((r) => r.issueUrl));
159
210
  const before = (this.state.savedResults ?? []).length;
160
211
  this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
161
212
  prunedCount = before - (this.state.savedResults?.length ?? 0);
@@ -166,16 +217,16 @@ export class OssScout {
166
217
  classifyVetResult(candidate) {
167
218
  const checks = candidate.vettingResult.checks;
168
219
  if (!checks.noExistingPR)
169
- return 'has_pr';
220
+ return "has_pr";
170
221
  if (!checks.notClaimed)
171
- return 'claimed';
172
- return 'still_available';
222
+ return "claimed";
223
+ return "still_available";
173
224
  }
174
225
  // ── State Reads (ScoutStateReader implementation) ───────────────────
175
226
  getReposWithMergedPRs() {
176
227
  const repoCounts = new Map();
177
228
  for (const pr of this.state.mergedPRs ?? []) {
178
- const repo = this.extractRepoFromUrl(pr.url);
229
+ const repo = extractRepoFromUrl(pr.url);
179
230
  if (repo) {
180
231
  repoCounts.set(repo, (repoCounts.get(repo) ?? 0) + 1);
181
232
  }
@@ -188,9 +239,6 @@ export class OssScout {
188
239
  getStarredRepos() {
189
240
  return this.state.starredRepos;
190
241
  }
191
- getPreferredOrgs() {
192
- return this.state.preferences.preferredOrgs;
193
- }
194
242
  getProjectCategories() {
195
243
  return this.state.preferences.projectCategories;
196
244
  }
@@ -323,6 +371,70 @@ export class OssScout {
323
371
  this.state.savedResults = [];
324
372
  this.dirty = true;
325
373
  }
374
+ // ── Skip List ───────────────────────────────────────────────────────
375
+ /**
376
+ * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
377
+ */
378
+ skipIssue(url, metadata) {
379
+ const existing = this.state.skippedIssues ?? [];
380
+ if (existing.some((s) => s.url === url))
381
+ return; // already skipped
382
+ this.state.skippedIssues = [
383
+ ...existing,
384
+ {
385
+ url,
386
+ repo: metadata?.repo ?? "",
387
+ number: metadata?.number ?? 0,
388
+ title: metadata?.title ?? "",
389
+ skippedAt: new Date().toISOString(),
390
+ },
391
+ ];
392
+ // Also remove from saved results if present
393
+ if (this.state.savedResults) {
394
+ this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url);
395
+ }
396
+ this.dirty = true;
397
+ }
398
+ /**
399
+ * Get all skipped issues.
400
+ */
401
+ getSkippedIssues() {
402
+ return this.state.skippedIssues ?? [];
403
+ }
404
+ /**
405
+ * Remove a specific issue from the skip list.
406
+ */
407
+ unskipIssue(url) {
408
+ this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url);
409
+ this.dirty = true;
410
+ }
411
+ /**
412
+ * Clear all skipped issues.
413
+ */
414
+ clearSkippedIssues() {
415
+ this.state.skippedIssues = [];
416
+ this.dirty = true;
417
+ }
418
+ /**
419
+ * Remove skipped issues older than maxDays (default 90). Called automatically during search.
420
+ * @returns The number of expired entries that were removed.
421
+ */
422
+ cullExpiredSkips(maxDays = 90) {
423
+ const cutoff = new Date();
424
+ cutoff.setDate(cutoff.getDate() - maxDays);
425
+ const before = (this.state.skippedIssues ?? []).length;
426
+ this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => {
427
+ const d = new Date(s.skippedAt);
428
+ if (isNaN(d.getTime())) {
429
+ return true; // keep entries with invalid dates rather than silently dropping
430
+ }
431
+ return d >= cutoff;
432
+ });
433
+ const culled = before - this.state.skippedIssues.length;
434
+ if (culled > 0)
435
+ this.dirty = true;
436
+ return culled;
437
+ }
326
438
  // ── Persistence ─────────────────────────────────────────────────────
327
439
  /**
328
440
  * Check if state has uncommitted changes.
@@ -353,21 +465,16 @@ export class OssScout {
353
465
  return this.state;
354
466
  }
355
467
  // ── Private helpers ─────────────────────────────────────────────────
356
- extractRepoFromUrl(url) {
357
- const match = url.match(/github\.com\/([^/]+\/[^/]+)\//);
358
- return match ? match[1] : null;
359
- }
360
468
  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;
469
+ const mergedCount = (this.state.mergedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
470
+ const closedCount = (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
363
471
  this.updateRepoScore(repo, {
364
472
  mergedPRCount: mergedCount,
365
473
  closedWithoutMergeCount: closedCount,
366
474
  lastMergedAt: mergedCount > 0
367
475
  ? (this.state.mergedPRs ?? [])
368
- .filter((p) => this.extractRepoFromUrl(p.url) === repo)
369
- .sort((a, b) => b.mergedAt.localeCompare(a.mergedAt))[0]
370
- ?.mergedAt
476
+ .filter((p) => extractRepoFromUrl(p.url) === repo)
477
+ .sort((a, b) => b.mergedAt.localeCompare(a.mergedAt))[0]?.mergedAt
371
478
  : undefined,
372
479
  });
373
480
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.2.0",
4
- "description": "Find open source issues personalized to your contribution history",
3
+ "version": "0.3.0",
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": {
7
7
  "oss-scout": "./dist/cli.bundle.cjs"
@@ -37,7 +37,11 @@
37
37
  "issue-discovery",
38
38
  "cli",
39
39
  "vetting",
40
- "contributions"
40
+ "contributions",
41
+ "claude",
42
+ "mcp-server",
43
+ "contribution-finder",
44
+ "personalized"
41
45
  ],
42
46
  "author": "John Costa",
43
47
  "license": "MIT",