@oss-scout/core 0.7.1 → 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.
- package/dist/cli.bundle.cjs +39 -39
- package/dist/core/bootstrap.js +2 -0
- package/dist/core/gist-state-store.d.ts +3 -0
- package/dist/core/gist-state-store.js +63 -6
- package/dist/core/issue-discovery.js +2 -0
- package/dist/core/issue-vetting.js +18 -1
- package/dist/core/repo-health.js +3 -0
- package/dist/core/search-phases.js +2 -0
- package/dist/scout.js +38 -4
- package/package.json +1 -1
package/dist/core/bootstrap.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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. ` +
|
package/dist/core/repo-health.js
CHANGED
|
@@ -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.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",
|
|
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
|
-
|
|
177
|
-
|
|
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:
|
|
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")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oss-scout/core",
|
|
3
|
-
"version": "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": {
|