@oss-scout/core 0.7.1 → 0.9.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/scout.js CHANGED
@@ -5,12 +5,30 @@
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
6
  */
7
7
  import { IssueDiscovery } from "./core/issue-discovery.js";
8
+ import { IssueVetter } from "./core/issue-vetting.js";
9
+ import { discoverFeatures, discoverFeaturesBroad, } from "./core/feature-discovery.js";
8
10
  import { ScoutStateSchema } from "./core/schemas.js";
9
11
  import { GistStateStore, mergeStates } from "./core/gist-state-store.js";
10
12
  import { getOctokit } from "./core/github.js";
11
13
  import { loadLocalState } from "./core/local-state.js";
12
14
  import { warn } from "./core/logger.js";
13
15
  import { extractRepoFromUrl } from "./core/utils.js";
16
+ import { errorMessage, getHttpStatusCode, isRateLimitError, } from "./core/errors.js";
17
+ /** Cause-specific user-facing message for degraded (offline) mode. */
18
+ function offlineModeMessage(reason) {
19
+ const tail = "Changes will only be saved locally.";
20
+ switch (reason) {
21
+ case "rate_limit":
22
+ return `Gist sync unavailable — GitHub API rate limit exceeded. ${tail} Try again after the rate limit resets.`;
23
+ case "network":
24
+ return `Gist sync unavailable — could not reach GitHub. ${tail} Check your network connection.`;
25
+ case "server":
26
+ return `Gist sync unavailable — GitHub returned a server error. ${tail} Try again later.`;
27
+ case "unknown":
28
+ case undefined:
29
+ return `Gist sync unavailable — running in offline mode. ${tail}`;
30
+ }
31
+ }
14
32
  /** Wrap a real Octokit instance as GistOctokitLike without unsafe double casts. */
15
33
  function toGistOctokit(octokit) {
16
34
  return {
@@ -84,7 +102,7 @@ export async function createScout(config) {
84
102
  gistStore = new GistStateStore(toGistOctokit(getOctokit(config.githubToken)));
85
103
  const result = await gistStore.bootstrap();
86
104
  if (result.degraded) {
87
- warn("scout", "Gist sync unavailable — running in offline mode. Changes will only be saved locally.");
105
+ warn("scout", offlineModeMessage(result.degradedReason));
88
106
  }
89
107
  const localState = loadLocalState();
90
108
  state = mergeStates(localState, result.state);
@@ -148,6 +166,48 @@ export class OssScout {
148
166
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
149
167
  return discovery.vetIssue(issueUrl);
150
168
  }
169
+ /**
170
+ * `scout features` — surfaces feature-scoped contribution opportunities
171
+ * in repos where the user has 3+ merged PRs (configurable via
172
+ * `featuresAnchorThreshold`), ranked into separate "quick wins" and
173
+ * "bigger bets" buckets (split via `featuresSplitRatio`).
174
+ *
175
+ * Per-call `anchorThreshold` and `splitRatio` overrides take precedence
176
+ * over the persisted preferences.
177
+ *
178
+ * When `broad` is true (#100), bypasses anchor resolution and runs a
179
+ * cross-repo GitHub Search query for first-touch contributors who
180
+ * haven't yet built repo relationships. Filters by user language
181
+ * preferences and excluded repos/orgs.
182
+ */
183
+ async features(options) {
184
+ const count = options?.count ?? 10;
185
+ const octokit = getOctokit(this.githubToken);
186
+ const vetter = new IssueVetter(octokit, this);
187
+ const result = options?.broad
188
+ ? await discoverFeaturesBroad({
189
+ octokit,
190
+ vetter,
191
+ count,
192
+ languages: this.state.preferences.languages,
193
+ excludeRepos: this.state.preferences.excludeRepos,
194
+ excludeOrgs: this.state.preferences.excludeOrgs,
195
+ splitRatio: options?.splitRatio ?? this.state.preferences.featuresSplitRatio,
196
+ })
197
+ : await discoverFeatures({
198
+ octokit,
199
+ vetter,
200
+ repoScores: this.state.repoScores ?? {},
201
+ count,
202
+ anchorThreshold: options?.anchorThreshold ??
203
+ this.state.preferences.featuresAnchorThreshold,
204
+ splitRatio: options?.splitRatio ?? this.state.preferences.featuresSplitRatio,
205
+ });
206
+ this.saveResults([...result.quickWins, ...result.biggerBets]);
207
+ this.state.lastSearchAt = new Date().toISOString();
208
+ this.dirty = true;
209
+ return result;
210
+ }
151
211
  // ── Batch Vetting ───────────────────────────────────────────────────
152
212
  /**
153
213
  * Re-vet all saved results with bounded concurrency.
@@ -159,7 +219,15 @@ export class OssScout {
159
219
  const concurrency = options?.concurrency ?? 5;
160
220
  const results = [];
161
221
  const pending = new Map();
222
+ // First 401 OR rate-limit short-circuits the whole batch. Unlike
223
+ // vetIssuesParallel (which has a batch-level rateLimitHit flag the
224
+ // search orchestrator surfaces via rateLimitWarning), vetList is the
225
+ // user-facing CLI entry point — N rows of "rate limit exceeded" is the
226
+ // exact silent-failure mode the documented strategy aims to prevent.
227
+ let firstHardError = null;
162
228
  for (const item of saved) {
229
+ if (firstHardError)
230
+ break;
163
231
  const task = this.vetIssue(item.issueUrl)
164
232
  .then((candidate) => {
165
233
  results.push({
@@ -173,15 +241,19 @@ export class OssScout {
173
241
  });
174
242
  })
175
243
  .catch((error) => {
176
- const msg = error instanceof Error ? error.message : String(error);
177
- const isGone = msg.includes("Not Found") || msg.includes("410");
244
+ if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
245
+ firstHardError ??= error;
246
+ return;
247
+ }
248
+ const status = getHttpStatusCode(error);
249
+ const isGone = status === 404 || status === 410;
178
250
  results.push({
179
251
  issueUrl: item.issueUrl,
180
252
  repo: item.repo,
181
253
  number: item.number,
182
254
  title: item.title,
183
255
  status: isGone ? "closed" : "error",
184
- errorMessage: msg,
256
+ errorMessage: errorMessage(error),
185
257
  });
186
258
  })
187
259
  .finally(() => {
@@ -193,6 +265,12 @@ export class OssScout {
193
265
  }
194
266
  }
195
267
  await Promise.allSettled(pending.values());
268
+ if (firstHardError) {
269
+ if (results.length > 0) {
270
+ warn("scout", `vetList aborted mid-batch after ${results.length} result(s) — discarding partial results due to auth/rate-limit failure`);
271
+ }
272
+ throw firstHardError;
273
+ }
196
274
  const summary = {
197
275
  total: results.length,
198
276
  stillAvailable: results.filter((r) => r.status === "still_available")
@@ -389,6 +467,7 @@ export class OssScout {
389
467
  firstSeenAt: prev?.firstSeenAt ?? now,
390
468
  lastSeenAt: now,
391
469
  lastScore: c.viabilityScore,
470
+ horizon: "horizon" in c ? c.horizon : undefined,
392
471
  });
393
472
  }
394
473
  this.state.savedResults = [...existing.values()];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.7.1",
3
+ "version": "0.9.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": {