@oss-scout/core 0.7.0 → 0.8.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.
@@ -51,6 +51,8 @@ export async function bootstrapScout(scout, token) {
51
51
  scout.setStarredRepos(starredRepos);
52
52
  }
53
53
  catch (err) {
54
+ if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
55
+ throw err;
54
56
  warn(MODULE, `Failed to fetch starred repos: ${errorMessage(err)}`);
55
57
  errors.push("starred repos fetch failed");
56
58
  }
@@ -50,11 +50,14 @@ export interface GistOctokitLike {
50
50
  }>;
51
51
  };
52
52
  }
53
+ /** Why bootstrap fell back to local-cache mode, when known. */
54
+ export type DegradedReason = "rate_limit" | "network" | "server" | "unknown";
53
55
  export interface BootstrapResult {
54
56
  gistId: string;
55
57
  state: ScoutState;
56
58
  created: boolean;
57
59
  degraded?: boolean;
60
+ degradedReason?: DegradedReason;
58
61
  }
59
62
  export declare class GistStateStore {
60
63
  private octokit;
@@ -9,13 +9,44 @@ import * as path from "path";
9
9
  import { ScoutStateSchema } from "./schemas.js";
10
10
  import { getDataDir } from "./utils.js";
11
11
  import { debug, warn } from "./logger.js";
12
- import { errorMessage } from "./errors.js";
12
+ import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
13
13
  const MODULE = "gist-state";
14
14
  const GIST_DESCRIPTION = "oss-scout-state";
15
15
  const GIST_FILENAME = "state.json";
16
16
  const GIST_ID_FILE = "gist-id";
17
17
  const CACHE_FILE = "state-cache.json";
18
18
  const SEARCH_MAX_PAGES = 5;
19
+ /** Classify an unknown error into a DegradedReason for user-facing messaging. */
20
+ function classifyDegradedReason(err) {
21
+ if (isRateLimitError(err))
22
+ return "rate_limit";
23
+ const status = getHttpStatusCode(err);
24
+ // GitHub's abuse-detection responses arrive as 403 with "abuse detection"
25
+ // in the message but no "rate limit" substring — match resolveErrorCode's
26
+ // logic in errors.ts so we don't misclassify as 'unknown'.
27
+ if (status === 403 &&
28
+ errorMessage(err).toLowerCase().includes("abuse detection")) {
29
+ return "rate_limit";
30
+ }
31
+ if (status !== undefined && status >= 500 && status < 600)
32
+ return "server";
33
+ if (err && typeof err === "object" && "code" in err) {
34
+ const code = err.code;
35
+ if (code === "ECONNREFUSED" ||
36
+ code === "ENOTFOUND" ||
37
+ code === "ETIMEDOUT" ||
38
+ code === "ECONNRESET" ||
39
+ code === "EAI_AGAIN") {
40
+ return "network";
41
+ }
42
+ }
43
+ // Node 18+ fetch errors arrive as `Error: fetch failed` with the cause set.
44
+ if (err instanceof Error &&
45
+ err.message.toLowerCase().includes("fetch failed")) {
46
+ return "network";
47
+ }
48
+ return "unknown";
49
+ }
19
50
  function getGistIdPath() {
20
51
  return path.join(getDataDir(), GIST_ID_FILE);
21
52
  }
@@ -37,8 +68,13 @@ export class GistStateStore {
37
68
  return await this.bootstrapFromApi();
38
69
  }
39
70
  catch (err) {
71
+ // 401 means the token is invalid — fail loudly so the user re-auths.
72
+ // Rate-limit / network / 5xx fall back to local cache so the user can
73
+ // keep working offline until the issue resolves.
74
+ if (getHttpStatusCode(err) === 401)
75
+ throw err;
40
76
  warn(MODULE, `API bootstrap failed: ${errorMessage(err)}`);
41
- return this.bootstrapFromCache();
77
+ return this.bootstrapFromCache(classifyDegradedReason(err));
42
78
  }
43
79
  }
44
80
  /**
@@ -66,6 +102,11 @@ export class GistStateStore {
66
102
  return true;
67
103
  }
68
104
  catch (err) {
105
+ // Both auth and rate-limit propagate per documented strategy.
106
+ // Local cache write already happened above, so the user's work isn't
107
+ // lost — but they need clear feedback that the sync failed.
108
+ if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
109
+ throw err;
69
110
  warn(MODULE, `Failed to push: ${errorMessage(err)}`);
70
111
  return false;
71
112
  }
@@ -84,6 +125,8 @@ export class GistStateStore {
84
125
  return state;
85
126
  }
86
127
  catch (err) {
128
+ if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
129
+ throw err;
87
130
  warn(MODULE, `Failed to pull: ${errorMessage(err)}`);
88
131
  return null;
89
132
  }
@@ -107,7 +150,14 @@ export class GistStateStore {
107
150
  }
108
151
  }
109
152
  catch (err) {
110
- debug(MODULE, `Cached gist ID invalid: ${errorMessage(err)}`);
153
+ // Only "the cached gist was deleted server-side" (404) justifies
154
+ // falling through to search. Auth/rate-limit/network must propagate
155
+ // so the outer bootstrap() catch can apply the documented strategy
156
+ // — otherwise a 401 here silently creates a brand-new empty gist
157
+ // for users with stale cached IDs.
158
+ if (getHttpStatusCode(err) !== 404)
159
+ throw err;
160
+ debug(MODULE, `Cached gist gone (404): ${errorMessage(err)}`);
111
161
  }
112
162
  debug(MODULE, "Cached gist ID invalid, searching...");
113
163
  }
@@ -125,7 +175,7 @@ export class GistStateStore {
125
175
  // Gist exists but content failed validation — fall back to cache
126
176
  // to avoid overwriting the user's data by creating a new gist.
127
177
  warn(MODULE, `Found existing gist ${foundId} but content failed validation. Using local cache to avoid data loss.`);
128
- return this.bootstrapFromCache();
178
+ return this.bootstrapFromCache("unknown");
129
179
  }
130
180
  // 3. Create new gist
131
181
  debug(MODULE, "No existing gist found, creating new one");
@@ -136,7 +186,7 @@ export class GistStateStore {
136
186
  this.writeCache(freshState);
137
187
  return { gistId: newId, state: freshState, created: true };
138
188
  }
139
- bootstrapFromCache() {
189
+ bootstrapFromCache(reason) {
140
190
  const cached = this.readCache();
141
191
  if (cached) {
142
192
  debug(MODULE, "Bootstrapped from local cache (degraded mode)");
@@ -148,11 +198,18 @@ export class GistStateStore {
148
198
  state: cached,
149
199
  created: false,
150
200
  degraded: true,
201
+ degradedReason: reason,
151
202
  };
152
203
  }
153
204
  debug(MODULE, "No cache available, using fresh state (degraded mode)");
154
205
  const fresh = ScoutStateSchema.parse({ version: 1 });
155
- return { gistId: "", state: fresh, created: false, degraded: true };
206
+ return {
207
+ gistId: "",
208
+ state: fresh,
209
+ created: false,
210
+ degraded: true,
211
+ degradedReason: reason,
212
+ };
156
213
  }
157
214
  // ── Gist API operations ──────────────────────────────────────────────
158
215
  async fetchGistState(gistId) {
@@ -203,6 +203,8 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
203
203
  };
204
204
  }
205
205
  catch (error) {
206
+ if (getHttpStatusCode(error) === 401)
207
+ throw error;
206
208
  const errMsg = errorMessage(error);
207
209
  warn(MODULE, `Error in maintained-repo search: ${errMsg}`);
208
210
  return {
@@ -7,7 +7,7 @@
7
7
  * - repo-health.ts — project health, contribution guidelines
8
8
  */
9
9
  import { parseGitHubUrl } from "./utils.js";
10
- import { ValidationError, errorMessage, isRateLimitError } from "./errors.js";
10
+ import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, } from "./errors.js";
11
11
  import { debug, warn } from "./logger.js";
12
12
  import { calculateRepoQualityBonus, calculateViabilityScore, } from "./issue-scoring.js";
13
13
  import { repoBelongsToCategory } from "./category-mapping.js";
@@ -263,9 +263,16 @@ export class IssueVetter {
263
263
  let failedVettingCount = 0;
264
264
  let rateLimitFailures = 0;
265
265
  let attemptedCount = 0;
266
+ // Capture the first 401 so we can re-throw after in-flight tasks settle.
267
+ // Per-item tolerance is right for transient failures, but a 401 means
268
+ // the token is invalid and no other issue will succeed either —
269
+ // continuing to log per-issue warnings buries the actual problem.
270
+ let firstAuthError = null;
266
271
  for (const url of urls) {
267
272
  if (candidates.length >= maxResults)
268
273
  break;
274
+ if (firstAuthError)
275
+ break; // stop scheduling once auth has failed
269
276
  attemptedCount++;
270
277
  const task = this.vetIssue(url)
271
278
  .then((candidate) => {
@@ -278,6 +285,10 @@ export class IssueVetter {
278
285
  }
279
286
  })
280
287
  .catch((error) => {
288
+ if (getHttpStatusCode(error) === 401) {
289
+ firstAuthError ??= error;
290
+ return;
291
+ }
281
292
  failedVettingCount++;
282
293
  if (isRateLimitError(error)) {
283
294
  rateLimitFailures++;
@@ -293,6 +304,12 @@ export class IssueVetter {
293
304
  }
294
305
  // Wait for remaining
295
306
  await Promise.allSettled(pending.values());
307
+ if (firstAuthError) {
308
+ if (candidates.length > 0) {
309
+ warn(MODULE, `Auth failed mid-batch after ${candidates.length} successful vet(s) — discarding partial results`);
310
+ }
311
+ throw firstAuthError;
312
+ }
296
313
  const allFailed = failedVettingCount === attemptedCount && attemptedCount > 0;
297
314
  if (allFailed) {
298
315
  warn(MODULE, `All ${attemptedCount} issue(s) failed vetting. ` +
@@ -73,6 +73,9 @@ export async function checkProjectHealth(octokit, owner, repo) {
73
73
  });
74
74
  }
75
75
  catch (error) {
76
+ if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
77
+ throw error;
78
+ }
76
79
  const errMsg = errorMessage(error);
77
80
  warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
78
81
  return {
@@ -370,6 +370,8 @@ export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labe
370
370
  }
371
371
  }
372
372
  catch (error) {
373
+ if (getHttpStatusCode(error) === 401)
374
+ throw error;
373
375
  failedBatches++;
374
376
  if (isRateLimitError(error)) {
375
377
  rateLimitFailures++;
package/dist/scout.d.ts CHANGED
@@ -63,6 +63,14 @@ export declare class OssScout implements ScoutStateReader {
63
63
  getStarredRepos(): string[];
64
64
  getProjectCategories(): ProjectCategory[];
65
65
  getRepoScore(repo: string): number | null;
66
+ /**
67
+ * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
68
+ * Empty `model` disables the call; the vetter treats it as a no-op.
69
+ */
70
+ getSLMTriageConfig(): {
71
+ model: string;
72
+ host: string;
73
+ };
66
74
  /** Get current preferences (read-only). */
67
75
  getPreferences(): Readonly<ScoutPreferences>;
68
76
  /** Get repo score record for a specific repository. */
package/dist/scout.js CHANGED
@@ -11,6 +11,22 @@ import { getOctokit } from "./core/github.js";
11
11
  import { loadLocalState } from "./core/local-state.js";
12
12
  import { warn } from "./core/logger.js";
13
13
  import { extractRepoFromUrl } from "./core/utils.js";
14
+ import { errorMessage, getHttpStatusCode, isRateLimitError, } from "./core/errors.js";
15
+ /** Cause-specific user-facing message for degraded (offline) mode. */
16
+ function offlineModeMessage(reason) {
17
+ const tail = "Changes will only be saved locally.";
18
+ switch (reason) {
19
+ case "rate_limit":
20
+ return `Gist sync unavailable — GitHub API rate limit exceeded. ${tail} Try again after the rate limit resets.`;
21
+ case "network":
22
+ return `Gist sync unavailable — could not reach GitHub. ${tail} Check your network connection.`;
23
+ case "server":
24
+ return `Gist sync unavailable — GitHub returned a server error. ${tail} Try again later.`;
25
+ case "unknown":
26
+ case undefined:
27
+ return `Gist sync unavailable — running in offline mode. ${tail}`;
28
+ }
29
+ }
14
30
  /** Wrap a real Octokit instance as GistOctokitLike without unsafe double casts. */
15
31
  function toGistOctokit(octokit) {
16
32
  return {
@@ -84,7 +100,7 @@ export async function createScout(config) {
84
100
  gistStore = new GistStateStore(toGistOctokit(getOctokit(config.githubToken)));
85
101
  const result = await gistStore.bootstrap();
86
102
  if (result.degraded) {
87
- warn("scout", "Gist sync unavailable — running in offline mode. Changes will only be saved locally.");
103
+ warn("scout", offlineModeMessage(result.degradedReason));
88
104
  }
89
105
  const localState = loadLocalState();
90
106
  state = mergeStates(localState, result.state);
@@ -159,7 +175,15 @@ export class OssScout {
159
175
  const concurrency = options?.concurrency ?? 5;
160
176
  const results = [];
161
177
  const pending = new Map();
178
+ // First 401 OR rate-limit short-circuits the whole batch. Unlike
179
+ // vetIssuesParallel (which has a batch-level rateLimitHit flag the
180
+ // search orchestrator surfaces via rateLimitWarning), vetList is the
181
+ // user-facing CLI entry point — N rows of "rate limit exceeded" is the
182
+ // exact silent-failure mode the documented strategy aims to prevent.
183
+ let firstHardError = null;
162
184
  for (const item of saved) {
185
+ if (firstHardError)
186
+ break;
163
187
  const task = this.vetIssue(item.issueUrl)
164
188
  .then((candidate) => {
165
189
  results.push({
@@ -173,15 +197,19 @@ export class OssScout {
173
197
  });
174
198
  })
175
199
  .catch((error) => {
176
- const msg = error instanceof Error ? error.message : String(error);
177
- const isGone = msg.includes("Not Found") || msg.includes("410");
200
+ if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
201
+ firstHardError ??= error;
202
+ return;
203
+ }
204
+ const status = getHttpStatusCode(error);
205
+ const isGone = status === 404 || status === 410;
178
206
  results.push({
179
207
  issueUrl: item.issueUrl,
180
208
  repo: item.repo,
181
209
  number: item.number,
182
210
  title: item.title,
183
211
  status: isGone ? "closed" : "error",
184
- errorMessage: msg,
212
+ errorMessage: errorMessage(error),
185
213
  });
186
214
  })
187
215
  .finally(() => {
@@ -193,6 +221,12 @@ export class OssScout {
193
221
  }
194
222
  }
195
223
  await Promise.allSettled(pending.values());
224
+ if (firstHardError) {
225
+ if (results.length > 0) {
226
+ warn("scout", `vetList aborted mid-batch after ${results.length} result(s) — discarding partial results due to auth/rate-limit failure`);
227
+ }
228
+ throw firstHardError;
229
+ }
196
230
  const summary = {
197
231
  total: results.length,
198
232
  stillAvailable: results.filter((r) => r.status === "still_available")
@@ -258,6 +292,16 @@ export class OssScout {
258
292
  const score = this.state.repoScores[repo];
259
293
  return score ? score.score : null;
260
294
  }
295
+ /**
296
+ * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
297
+ * Empty `model` disables the call; the vetter treats it as a no-op.
298
+ */
299
+ getSLMTriageConfig() {
300
+ return {
301
+ model: this.state.preferences.slmTriageModel ?? "",
302
+ host: this.state.preferences.slmTriageHost ?? "",
303
+ };
304
+ }
261
305
  /** Get current preferences (read-only). */
262
306
  getPreferences() {
263
307
  return this.state.preferences;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {