@oss-scout/core 0.11.0 → 1.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.
- package/dist/cli.bundle.cjs +89 -66
- package/dist/cli.js +302 -436
- package/dist/commands/command-scout.d.ts +21 -0
- package/dist/commands/command-scout.js +21 -0
- package/dist/commands/config.js +10 -128
- package/dist/commands/features.js +15 -28
- package/dist/commands/results.d.ts +13 -2
- package/dist/commands/results.js +29 -2
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +65 -70
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +35 -6
- package/dist/commands/skip.d.ts +4 -0
- package/dist/commands/skip.js +45 -55
- package/dist/commands/sync.d.ts +10 -0
- package/dist/commands/sync.js +10 -0
- package/dist/commands/vet-list.js +3 -19
- package/dist/commands/vet.js +18 -25
- package/dist/commands/with-scout.d.ts +32 -0
- package/dist/commands/with-scout.js +41 -0
- package/dist/core/anti-llm-policy.js +5 -33
- package/dist/core/bootstrap.d.ts +2 -2
- package/dist/core/bootstrap.js +5 -9
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.js +20 -5
- package/dist/core/feature-discovery.d.ts +13 -1
- package/dist/core/feature-discovery.js +104 -81
- package/dist/core/gist-state-store.d.ts +13 -12
- package/dist/core/gist-state-store.js +128 -53
- package/dist/core/http-cache.d.ts +32 -2
- package/dist/core/http-cache.js +74 -19
- package/dist/core/issue-discovery.d.ts +12 -1
- package/dist/core/issue-discovery.js +94 -67
- package/dist/core/issue-eligibility.d.ts +11 -4
- package/dist/core/issue-eligibility.js +124 -69
- package/dist/core/issue-graphql.d.ts +58 -0
- package/dist/core/issue-graphql.js +108 -0
- package/dist/core/issue-vetting.d.ts +115 -9
- package/dist/core/issue-vetting.js +246 -109
- package/dist/core/local-state.d.ts +6 -2
- package/dist/core/local-state.js +23 -5
- package/dist/core/logger.d.ts +12 -4
- package/dist/core/logger.js +33 -7
- package/dist/core/personalization.d.ts +30 -10
- package/dist/core/personalization.js +64 -24
- package/dist/core/preference-fields.d.ts +47 -0
- package/dist/core/preference-fields.js +180 -0
- package/dist/core/probe-repo-file.d.ts +47 -0
- package/dist/core/probe-repo-file.js +57 -0
- package/dist/core/repo-health.js +40 -32
- package/dist/core/roadmap.js +26 -22
- package/dist/core/schemas.d.ts +148 -26
- package/dist/core/schemas.js +83 -17
- package/dist/core/search-budget.d.ts +9 -0
- package/dist/core/search-budget.js +36 -3
- package/dist/core/search-phases.d.ts +4 -21
- package/dist/core/search-phases.js +37 -89
- package/dist/core/types.d.ts +151 -38
- package/dist/core/utils.js +60 -26
- package/dist/formatters/human.d.ts +60 -0
- package/dist/formatters/human.js +199 -0
- package/dist/formatters/markdown.d.ts +10 -0
- package/dist/formatters/markdown.js +31 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +8 -0
- package/dist/scout.d.ts +75 -12
- package/dist/scout.js +265 -26
- package/package.json +1 -1
package/dist/scout.js
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { IssueDiscovery } from "./core/issue-discovery.js";
|
|
8
8
|
import { IssueVetter } from "./core/issue-vetting.js";
|
|
9
9
|
import { discoverFeatures, discoverFeaturesBroad, } from "./core/feature-discovery.js";
|
|
10
|
-
import { ScoutStateSchema } from "./core/schemas.js";
|
|
11
10
|
import { GistStateStore, mergeStates } from "./core/gist-state-store.js";
|
|
12
11
|
import { getOctokit } from "./core/github.js";
|
|
13
|
-
import { loadLocalState } from "./core/local-state.js";
|
|
14
|
-
import { warn } from "./core/logger.js";
|
|
15
|
-
import { extractRepoFromUrl } from "./core/utils.js";
|
|
16
|
-
import { errorMessage, getHttpStatusCode, isRateLimitError, } from "./core/errors.js";
|
|
12
|
+
import { loadLocalState, saveLocalState } from "./core/local-state.js";
|
|
13
|
+
import { warn, setLogLevel } from "./core/logger.js";
|
|
14
|
+
import { extractRepoFromUrl, parseGitHubUrl } from "./core/utils.js";
|
|
15
|
+
import { errorMessage, getHttpStatusCode, isRateLimitError, rethrowIfFatal, } from "./core/errors.js";
|
|
16
|
+
import { getHttpCache } from "./core/http-cache.js";
|
|
17
17
|
/** Cause-specific user-facing message for degraded (offline) mode. */
|
|
18
18
|
function offlineModeMessage(reason) {
|
|
19
19
|
const tail = "Changes will only be saved locally.";
|
|
@@ -29,8 +29,12 @@ function offlineModeMessage(reason) {
|
|
|
29
29
|
return `Gist sync unavailable — running in offline mode. ${tail}`;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a real Octokit instance as GistOctokitLike without unsafe double casts.
|
|
34
|
+
* Exported (not via the package index) so the response-narrowing logic — the
|
|
35
|
+
* "no id" guards and the files/list mapping — is unit-testable (#162).
|
|
36
|
+
*/
|
|
37
|
+
export function toGistOctokit(octokit) {
|
|
34
38
|
return {
|
|
35
39
|
gists: {
|
|
36
40
|
async get(params) {
|
|
@@ -93,8 +97,13 @@ function toGistOctokit(octokit) {
|
|
|
93
97
|
* ```
|
|
94
98
|
*/
|
|
95
99
|
export async function createScout(config) {
|
|
100
|
+
// Apply the host's log-level preference before any bootstrap chatter (#156).
|
|
101
|
+
if (config.logLevel !== undefined) {
|
|
102
|
+
setLogLevel(config.logLevel);
|
|
103
|
+
}
|
|
96
104
|
let state;
|
|
97
105
|
let gistStore = null;
|
|
106
|
+
let persistLocal = false;
|
|
98
107
|
if (config.persistence === "provided") {
|
|
99
108
|
state = config.initialState;
|
|
100
109
|
}
|
|
@@ -114,9 +123,15 @@ export async function createScout(config) {
|
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
else {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
// Default: local-file persistence. The previous else-branch silently
|
|
127
|
+
// created throwaway in-memory state, so a documented standalone scout
|
|
128
|
+
// (and the MCP server) read no preferences and persisted nothing while
|
|
129
|
+
// checkpoint() reported success (#116). Load the real state and save it
|
|
130
|
+
// on checkpoint.
|
|
131
|
+
state = loadLocalState();
|
|
132
|
+
persistLocal = true;
|
|
133
|
+
}
|
|
134
|
+
return new OssScout(config.githubToken, state, gistStore, { persistLocal });
|
|
120
135
|
}
|
|
121
136
|
/**
|
|
122
137
|
* Main oss-scout class. Provides search, vetting, and state management.
|
|
@@ -129,29 +144,64 @@ export class OssScout {
|
|
|
129
144
|
gistStore;
|
|
130
145
|
state;
|
|
131
146
|
dirty = false;
|
|
132
|
-
|
|
147
|
+
/** When true, checkpoint() also writes ~/.oss-scout/state.json. */
|
|
148
|
+
persistLocal;
|
|
149
|
+
constructor(githubToken, initialState, gistStore = null, opts = {}) {
|
|
133
150
|
this.githubToken = githubToken;
|
|
134
151
|
this.gistStore = gistStore;
|
|
135
152
|
this.state = initialState;
|
|
153
|
+
this.persistLocal = opts.persistLocal ?? false;
|
|
136
154
|
}
|
|
137
155
|
// ── Search ──────────────────────────────────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* Drop stale disk-cache entries. Called at the top of every cache-burning
|
|
158
|
+
* entry point (search, features, vetList); without it ~/.oss-scout/cache
|
|
159
|
+
* grows without bound. evictStale never throws (fs errors degrade to warn).
|
|
160
|
+
*/
|
|
161
|
+
evictStaleCacheEntries() {
|
|
162
|
+
getHttpCache().evictStale();
|
|
163
|
+
}
|
|
138
164
|
/**
|
|
139
165
|
* Multi-strategy issue search. Returns scored, sorted candidates.
|
|
140
166
|
* Automatically culls expired skip entries and filters skipped issues.
|
|
141
167
|
*/
|
|
142
168
|
async search(options) {
|
|
169
|
+
this.evictStaleCacheEntries();
|
|
143
170
|
// Auto-cull expired skips before searching
|
|
144
171
|
this.cullExpiredSkips();
|
|
145
172
|
const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
|
|
146
173
|
const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
|
|
174
|
+
// Per-call flags override the persisted personalization defaults (#168).
|
|
175
|
+
// An empty preference array reads as "no boost" just like an absent flag.
|
|
176
|
+
const prefs = this.state.preferences;
|
|
177
|
+
const prefLangs = prefs.preferLanguages ?? [];
|
|
178
|
+
const prefRepos = prefs.preferRepos ?? [];
|
|
179
|
+
const preferLanguages = options?.preferLanguages ??
|
|
180
|
+
(prefLangs.length > 0 ? prefLangs : undefined);
|
|
181
|
+
const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : undefined);
|
|
182
|
+
const prefAvoid = prefs.avoidRepos ?? [];
|
|
183
|
+
const prefBoostTypes = prefs.boostIssueTypes ?? [];
|
|
184
|
+
const avoidRepos = options?.avoidRepos ?? (prefAvoid.length > 0 ? prefAvoid : undefined);
|
|
185
|
+
const boostIssueTypes = options?.boostIssueTypes ??
|
|
186
|
+
(prefBoostTypes.length > 0 ? prefBoostTypes : undefined);
|
|
187
|
+
const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
|
|
147
188
|
const { candidates, strategiesUsed } = await discovery.searchIssues({
|
|
148
189
|
maxResults: options?.maxResults,
|
|
149
190
|
strategies: options?.strategies,
|
|
150
191
|
skippedUrls,
|
|
151
|
-
preferLanguages
|
|
152
|
-
preferRepos
|
|
153
|
-
|
|
192
|
+
preferLanguages,
|
|
193
|
+
preferRepos,
|
|
194
|
+
avoidRepos,
|
|
195
|
+
boostIssueTypes,
|
|
196
|
+
diversityRatio,
|
|
197
|
+
interPhaseDelayMs: options?.interPhaseDelayMs,
|
|
198
|
+
broadPhaseDelayMs: options?.broadPhaseDelayMs,
|
|
154
199
|
});
|
|
200
|
+
// Feed the freshly observed maintainer-responsiveness signals back into the
|
|
201
|
+
// repo scores so the next search ranks responsive/active repos higher (#167).
|
|
202
|
+
for (const c of candidates) {
|
|
203
|
+
this.updateRepoSignalsFromHealth(c.projectHealth);
|
|
204
|
+
}
|
|
155
205
|
this.state.lastSearchAt = new Date().toISOString();
|
|
156
206
|
this.dirty = true;
|
|
157
207
|
return {
|
|
@@ -162,6 +212,27 @@ export class OssScout {
|
|
|
162
212
|
strategiesUsed,
|
|
163
213
|
};
|
|
164
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Populate the `hasActiveMaintainers` repo-score signal from a freshly
|
|
217
|
+
* computed projectHealth (#167). It was initialized false and never set, so
|
|
218
|
+
* calculateScore's +1 active-maintainers weight was inert; `isActive`
|
|
219
|
+
* (recent commit activity) is a real, already-computed proxy.
|
|
220
|
+
*
|
|
221
|
+
* `isResponsive` and `avgResponseDays` are deliberately NOT set here, and
|
|
222
|
+
* `isResponsive` no longer carries a score weight at all (#167): real
|
|
223
|
+
* responsiveness needs an actual response-time measurement (extra API calls)
|
|
224
|
+
* that is out of scope, and `hasActiveMaintainers` already covers the
|
|
225
|
+
* activity proxy. `hasHostileComments` stays a host-settable capability (it
|
|
226
|
+
* needs comment sentiment, out of scope). A failed health check is skipped so
|
|
227
|
+
* its neutral-default fields don't pollute the score.
|
|
228
|
+
*/
|
|
229
|
+
updateRepoSignalsFromHealth(health) {
|
|
230
|
+
if (health.checkFailed)
|
|
231
|
+
return;
|
|
232
|
+
this.updateRepoScore(health.repo, {
|
|
233
|
+
signals: { hasActiveMaintainers: health.isActive },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
165
236
|
/**
|
|
166
237
|
* Vet a single issue URL for claimability.
|
|
167
238
|
*/
|
|
@@ -184,6 +255,7 @@ export class OssScout {
|
|
|
184
255
|
* preferences and excluded repos/orgs.
|
|
185
256
|
*/
|
|
186
257
|
async features(options) {
|
|
258
|
+
this.evictStaleCacheEntries();
|
|
187
259
|
const count = options?.count ?? 10;
|
|
188
260
|
const octokit = getOctokit(this.githubToken);
|
|
189
261
|
const vetter = new IssueVetter(octokit, this);
|
|
@@ -218,6 +290,7 @@ export class OssScout {
|
|
|
218
290
|
* Optionally prunes unavailable issues from saved results.
|
|
219
291
|
*/
|
|
220
292
|
async vetList(options) {
|
|
293
|
+
this.evictStaleCacheEntries();
|
|
221
294
|
const saved = this.getSavedResults();
|
|
222
295
|
const concurrency = options?.concurrency ?? 5;
|
|
223
296
|
const results = [];
|
|
@@ -239,6 +312,7 @@ export class OssScout {
|
|
|
239
312
|
number: item.number,
|
|
240
313
|
title: item.title,
|
|
241
314
|
status: this.classifyVetResult(candidate),
|
|
315
|
+
ok: true,
|
|
242
316
|
recommendation: candidate.recommendation,
|
|
243
317
|
viabilityScore: candidate.viabilityScore,
|
|
244
318
|
});
|
|
@@ -256,6 +330,7 @@ export class OssScout {
|
|
|
256
330
|
number: item.number,
|
|
257
331
|
title: item.title,
|
|
258
332
|
status: isGone ? "closed" : "error",
|
|
333
|
+
ok: false,
|
|
259
334
|
errorMessage: errorMessage(error),
|
|
260
335
|
});
|
|
261
336
|
})
|
|
@@ -283,6 +358,33 @@ export class OssScout {
|
|
|
283
358
|
hasPR: results.filter((r) => r.status === "has_pr").length,
|
|
284
359
|
errors: results.filter((r) => r.status === "error").length,
|
|
285
360
|
};
|
|
361
|
+
// Claim-watch (#165): compare each result's current status to the status
|
|
362
|
+
// recorded on the saved result last time, then persist the new status so
|
|
363
|
+
// the next run can diff again. "error" is transient — never a transition
|
|
364
|
+
// target and never stored.
|
|
365
|
+
const prevStatus = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r.lastStatus]));
|
|
366
|
+
const transitions = results
|
|
367
|
+
.filter((r) => r.status !== "error")
|
|
368
|
+
.filter((r) => {
|
|
369
|
+
const prev = prevStatus.get(r.issueUrl);
|
|
370
|
+
return prev !== undefined && prev !== r.status;
|
|
371
|
+
})
|
|
372
|
+
.map((r) => ({
|
|
373
|
+
issueUrl: r.issueUrl,
|
|
374
|
+
repo: r.repo,
|
|
375
|
+
number: r.number,
|
|
376
|
+
from: prevStatus.get(r.issueUrl),
|
|
377
|
+
to: r.status,
|
|
378
|
+
}));
|
|
379
|
+
const currentStatus = new Map(results.map((r) => [r.issueUrl, r.status]));
|
|
380
|
+
for (const saved of this.state.savedResults ?? []) {
|
|
381
|
+
const status = currentStatus.get(saved.issueUrl);
|
|
382
|
+
if (status !== undefined && status !== "error") {
|
|
383
|
+
saved.lastStatus = status;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (results.length > 0)
|
|
387
|
+
this.dirty = true;
|
|
286
388
|
let prunedCount;
|
|
287
389
|
if (options?.prune) {
|
|
288
390
|
const unavailableUrls = new Set(results
|
|
@@ -291,11 +393,18 @@ export class OssScout {
|
|
|
291
393
|
const before = (this.state.savedResults ?? []).length;
|
|
292
394
|
this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
|
|
293
395
|
prunedCount = before - (this.state.savedResults?.length ?? 0);
|
|
396
|
+
if (prunedCount > 0)
|
|
397
|
+
this.addTombstones([...unavailableUrls]);
|
|
294
398
|
this.dirty = true;
|
|
295
399
|
}
|
|
296
|
-
return { results, summary, prunedCount };
|
|
400
|
+
return { results, summary, prunedCount, transitions };
|
|
297
401
|
}
|
|
298
402
|
classifyVetResult(candidate) {
|
|
403
|
+
// Closed wins over everything: GitHub returns 200 for closed issues, so
|
|
404
|
+
// the 404/410 catch path alone never saw them (#120). Candidates cached
|
|
405
|
+
// by older versions lack issueState and read as open.
|
|
406
|
+
if (candidate.issueState === "closed")
|
|
407
|
+
return "closed";
|
|
299
408
|
const checks = candidate.vettingResult.checks;
|
|
300
409
|
if (!checks.noExistingPR)
|
|
301
410
|
return "has_pr";
|
|
@@ -335,19 +444,35 @@ export class OssScout {
|
|
|
335
444
|
getProjectCategories() {
|
|
336
445
|
return this.state.preferences.projectCategories;
|
|
337
446
|
}
|
|
447
|
+
/** Configured GitHub username (used to classify own vs competing PRs, #166). */
|
|
448
|
+
getGitHubUsername() {
|
|
449
|
+
return this.state.preferences.githubUsername;
|
|
450
|
+
}
|
|
338
451
|
getRepoScore(repo) {
|
|
339
452
|
const score = this.state.repoScores[repo];
|
|
340
453
|
return score ? score.score : null;
|
|
341
454
|
}
|
|
342
455
|
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
456
|
+
* Number of the user's PRs closed without merge in this repo (#125).
|
|
457
|
+
* Prefers the tracked repo score; falls back to counting closedPRs so the
|
|
458
|
+
* scoring penalty works even before a score record exists.
|
|
459
|
+
*/
|
|
460
|
+
getClosedWithoutMergeCount(repo) {
|
|
461
|
+
const score = this.state.repoScores[repo];
|
|
462
|
+
if (score)
|
|
463
|
+
return score.closedWithoutMergeCount;
|
|
464
|
+
return (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
|
|
468
|
+
* `null` when no `slmTriageModel` is configured — the vetter skips the SLM
|
|
469
|
+
* call entirely (#158).
|
|
345
470
|
*/
|
|
346
471
|
getSLMTriageConfig() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
};
|
|
472
|
+
const model = this.state.preferences.slmTriageModel ?? "";
|
|
473
|
+
if (!model)
|
|
474
|
+
return null;
|
|
475
|
+
return { model, host: this.state.preferences.slmTriageHost ?? "" };
|
|
351
476
|
}
|
|
352
477
|
/** Get current preferences (read-only). */
|
|
353
478
|
getPreferences() {
|
|
@@ -402,6 +527,78 @@ export class OssScout {
|
|
|
402
527
|
];
|
|
403
528
|
this.dirty = true;
|
|
404
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Reconcile tracked open PRs against their current GitHub state (#164).
|
|
532
|
+
*
|
|
533
|
+
* `state.openPRs` was append-only — nothing transitioned an open PR to
|
|
534
|
+
* merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
|
|
535
|
+
* merged. This checks each open PR, records merges/closures (which updates
|
|
536
|
+
* the repo score), prunes resolved entries, and checkpoints. Cheaper than a
|
|
537
|
+
* full bootstrap, so a host can call it on daily startup. Transient errors
|
|
538
|
+
* leave the entry in place; auth/rate-limit failures propagate.
|
|
539
|
+
*/
|
|
540
|
+
async syncOpenPRs() {
|
|
541
|
+
const octokit = getOctokit(this.githubToken);
|
|
542
|
+
const open = this.state.openPRs ?? [];
|
|
543
|
+
const result = {
|
|
544
|
+
checked: open.length,
|
|
545
|
+
merged: 0,
|
|
546
|
+
closed: 0,
|
|
547
|
+
stillOpen: 0,
|
|
548
|
+
errors: 0,
|
|
549
|
+
};
|
|
550
|
+
const remaining = [];
|
|
551
|
+
for (const pr of open) {
|
|
552
|
+
const parsed = parseGitHubUrl(pr.url);
|
|
553
|
+
if (!parsed || parsed.type !== "pull") {
|
|
554
|
+
remaining.push(pr);
|
|
555
|
+
result.errors++;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const repoFullName = `${parsed.owner}/${parsed.repo}`;
|
|
559
|
+
try {
|
|
560
|
+
const { data } = await octokit.pulls.get({
|
|
561
|
+
owner: parsed.owner,
|
|
562
|
+
repo: parsed.repo,
|
|
563
|
+
pull_number: parsed.number,
|
|
564
|
+
});
|
|
565
|
+
if (data.merged) {
|
|
566
|
+
this.recordMergedPR({
|
|
567
|
+
url: pr.url,
|
|
568
|
+
title: pr.title,
|
|
569
|
+
mergedAt: data.merged_at ?? new Date().toISOString(),
|
|
570
|
+
repo: repoFullName,
|
|
571
|
+
});
|
|
572
|
+
result.merged++;
|
|
573
|
+
}
|
|
574
|
+
else if (data.state === "closed") {
|
|
575
|
+
this.recordClosedPR({
|
|
576
|
+
url: pr.url,
|
|
577
|
+
title: pr.title,
|
|
578
|
+
closedAt: data.closed_at ?? new Date().toISOString(),
|
|
579
|
+
repo: repoFullName,
|
|
580
|
+
});
|
|
581
|
+
result.closed++;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
remaining.push(pr);
|
|
585
|
+
result.stillOpen++;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
// Auth/rate-limit aborts the whole sync; a transient/404 leaves the
|
|
590
|
+
// entry untouched so a later sync can retry rather than losing it.
|
|
591
|
+
rethrowIfFatal(err);
|
|
592
|
+
warn("scout", `sync: could not check ${pr.url}: ${errorMessage(err)}`);
|
|
593
|
+
remaining.push(pr);
|
|
594
|
+
result.errors++;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
this.state.openPRs = remaining;
|
|
598
|
+
this.dirty = true;
|
|
599
|
+
await this.checkpoint();
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
405
602
|
/**
|
|
406
603
|
* Update repo score with observed signals.
|
|
407
604
|
*/
|
|
@@ -437,6 +634,9 @@ export class OssScout {
|
|
|
437
634
|
*/
|
|
438
635
|
updatePreferences(updates) {
|
|
439
636
|
this.state.preferences = { ...this.state.preferences, ...updates };
|
|
637
|
+
// Stamp so the gist merge keeps the fresher preferences instead of
|
|
638
|
+
// always taking the remote copy (#117).
|
|
639
|
+
this.state.preferencesUpdatedAt = new Date().toISOString();
|
|
440
640
|
this.dirty = true;
|
|
441
641
|
}
|
|
442
642
|
/**
|
|
@@ -486,9 +686,25 @@ export class OssScout {
|
|
|
486
686
|
* Clear all saved results.
|
|
487
687
|
*/
|
|
488
688
|
clearResults() {
|
|
689
|
+
this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
|
|
489
690
|
this.state.savedResults = [];
|
|
490
691
|
this.dirty = true;
|
|
491
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Record deletion tombstones (#117) so a later gist merge does not
|
|
695
|
+
* resurrect these URLs from another machine's copy. A re-add with a newer
|
|
696
|
+
* timestamp overrides the tombstone in mergeStates.
|
|
697
|
+
*/
|
|
698
|
+
addTombstones(urls) {
|
|
699
|
+
if (urls.length === 0)
|
|
700
|
+
return;
|
|
701
|
+
const removedAt = new Date().toISOString();
|
|
702
|
+
const existing = this.state.tombstones ?? [];
|
|
703
|
+
const byUrl = new Map(existing.map((t) => [t.url, t]));
|
|
704
|
+
for (const url of urls)
|
|
705
|
+
byUrl.set(url, { url, removedAt });
|
|
706
|
+
this.state.tombstones = [...byUrl.values()];
|
|
707
|
+
}
|
|
492
708
|
// ── Skip List ───────────────────────────────────────────────────────
|
|
493
709
|
/**
|
|
494
710
|
* Skip an issue — excludes it from future searches. Auto-culled after 90 days.
|
|
@@ -507,7 +723,10 @@ export class OssScout {
|
|
|
507
723
|
skippedAt: new Date().toISOString(),
|
|
508
724
|
},
|
|
509
725
|
];
|
|
510
|
-
// Also remove from saved results if present
|
|
726
|
+
// Also remove from saved results if present. No tombstone needed: the
|
|
727
|
+
// skip entry itself is the durable record, and mergeStates reconciles
|
|
728
|
+
// saved results against the skip list so a merge can't resurrect a
|
|
729
|
+
// skipped URL into the saved list (#117).
|
|
511
730
|
if (this.state.savedResults) {
|
|
512
731
|
this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url);
|
|
513
732
|
}
|
|
@@ -523,13 +742,17 @@ export class OssScout {
|
|
|
523
742
|
* Remove a specific issue from the skip list.
|
|
524
743
|
*/
|
|
525
744
|
unskipIssue(url) {
|
|
745
|
+
const before = (this.state.skippedIssues ?? []).length;
|
|
526
746
|
this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url);
|
|
747
|
+
if (this.state.skippedIssues.length < before)
|
|
748
|
+
this.addTombstones([url]);
|
|
527
749
|
this.dirty = true;
|
|
528
750
|
}
|
|
529
751
|
/**
|
|
530
752
|
* Clear all skipped issues.
|
|
531
753
|
*/
|
|
532
754
|
clearSkippedIssues() {
|
|
755
|
+
this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
|
|
533
756
|
this.state.skippedIssues = [];
|
|
534
757
|
this.dirty = true;
|
|
535
758
|
}
|
|
@@ -573,6 +796,17 @@ export class OssScout {
|
|
|
573
796
|
if (!ok)
|
|
574
797
|
return false;
|
|
575
798
|
}
|
|
799
|
+
if (this.persistLocal) {
|
|
800
|
+
// Honest persistence: in local mode the previous no-op return true
|
|
801
|
+
// claimed success while saving nothing (#116). A failed write keeps
|
|
802
|
+
// the dirty flag and reports failure.
|
|
803
|
+
try {
|
|
804
|
+
saveLocalState(this.state);
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
576
810
|
this.dirty = false;
|
|
577
811
|
return true;
|
|
578
812
|
}
|
|
@@ -597,16 +831,21 @@ export class OssScout {
|
|
|
597
831
|
});
|
|
598
832
|
}
|
|
599
833
|
/**
|
|
600
|
-
* Calculate repo score
|
|
834
|
+
* Calculate repo score from observed data.
|
|
601
835
|
* base 5, +1 per merged PR (max +3), -1 per closed-without-merge (max -3),
|
|
602
|
-
* +1
|
|
836
|
+
* +1 active maintainers, -2 hostile comments, clamped to [1, 10].
|
|
837
|
+
*
|
|
838
|
+
* `isResponsive` is intentionally NOT scored (#167): nothing in oss-scout
|
|
839
|
+
* ever computes it, so awarding +1 for it was dead weight that the
|
|
840
|
+
* now-computed `hasActiveMaintainers` signal already covers as the activity
|
|
841
|
+
* proxy. The field is retained on RepoSignals for backward compatibility but
|
|
842
|
+
* no longer affects the score. With the current signals the reachable range
|
|
843
|
+
* is [1, 9]; the upper clamp stays as defensive hygiene.
|
|
603
844
|
*/
|
|
604
845
|
calculateScore(repoScore) {
|
|
605
846
|
let score = 5;
|
|
606
847
|
score += Math.min(repoScore.mergedPRCount, 3);
|
|
607
848
|
score -= Math.min(repoScore.closedWithoutMergeCount, 3);
|
|
608
|
-
if (repoScore.signals.isResponsive)
|
|
609
|
-
score += 1;
|
|
610
849
|
if (repoScore.signals.hasActiveMaintainers)
|
|
611
850
|
score += 1;
|
|
612
851
|
if (repoScore.signals.hasHostileComments)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oss-scout/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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": {
|