@oss-scout/core 0.10.0 → 1.0.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 +77 -60
- package/dist/cli.js +403 -416
- 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 +7 -0
- package/dist/commands/search.js +63 -68
- 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 +4 -5
- 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 +3 -0
- package/dist/core/issue-discovery.js +51 -31
- package/dist/core/issue-eligibility.d.ts +10 -4
- package/dist/core/issue-eligibility.js +119 -67
- package/dist/core/issue-graphql.d.ts +58 -0
- package/dist/core/issue-graphql.js +108 -0
- package/dist/core/issue-vetting.d.ts +105 -8
- package/dist/core/issue-vetting.js +234 -107
- 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 +51 -18
- package/dist/core/personalization.js +101 -27
- package/dist/core/preference-fields.d.ts +47 -0
- package/dist/core/preference-fields.js +178 -0
- package/dist/core/repo-health.js +31 -15
- package/dist/core/roadmap.js +17 -3
- package/dist/core/schemas.d.ts +144 -26
- package/dist/core/schemas.js +74 -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 +0 -18
- package/dist/core/search-phases.js +27 -82
- package/dist/core/types.d.ts +146 -30
- package/dist/core/utils.js +60 -26
- 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 +59 -10
- package/dist/scout.js +244 -19
- package/package.json +1 -1
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";
|
|
7
|
+
import type { ScoutStateReader, ScoutStateWriter, SLMConfig } from "./core/issue-vetting.js";
|
|
8
8
|
import { type FeatureSearchResult } from "./core/feature-discovery.js";
|
|
9
9
|
import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue, Horizon } from "./core/schemas.js";
|
|
10
|
-
import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from "./core/types.js";
|
|
10
|
+
import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, SyncResult, VetListOptions, VetListResult } from "./core/types.js";
|
|
11
11
|
import { GistStateStore } from "./core/gist-state-store.js";
|
|
12
12
|
/**
|
|
13
13
|
* Create an OssScout instance.
|
|
@@ -37,17 +37,43 @@ export declare function createScout(config: ScoutConfig): Promise<OssScout>;
|
|
|
37
37
|
* Implements ScoutStateReader so the search engine can read state
|
|
38
38
|
* without knowing about the persistence layer.
|
|
39
39
|
*/
|
|
40
|
-
export declare class OssScout implements ScoutStateReader {
|
|
40
|
+
export declare class OssScout implements ScoutStateReader, ScoutStateWriter {
|
|
41
41
|
private githubToken;
|
|
42
42
|
private gistStore;
|
|
43
43
|
private state;
|
|
44
44
|
private dirty;
|
|
45
|
-
|
|
45
|
+
/** When true, checkpoint() also writes ~/.oss-scout/state.json. */
|
|
46
|
+
private persistLocal;
|
|
47
|
+
constructor(githubToken: string, initialState: ScoutState, gistStore?: GistStateStore | null, opts?: {
|
|
48
|
+
persistLocal?: boolean;
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* Drop stale disk-cache entries. Called at the top of every cache-burning
|
|
52
|
+
* entry point (search, features, vetList); without it ~/.oss-scout/cache
|
|
53
|
+
* grows without bound. evictStale never throws (fs errors degrade to warn).
|
|
54
|
+
*/
|
|
55
|
+
private evictStaleCacheEntries;
|
|
46
56
|
/**
|
|
47
57
|
* Multi-strategy issue search. Returns scored, sorted candidates.
|
|
48
58
|
* Automatically culls expired skip entries and filters skipped issues.
|
|
49
59
|
*/
|
|
50
60
|
search(options?: SearchOptions): Promise<SearchResult>;
|
|
61
|
+
/**
|
|
62
|
+
* Populate the `hasActiveMaintainers` repo-score signal from a freshly
|
|
63
|
+
* computed projectHealth (#167). It was initialized false and never set, so
|
|
64
|
+
* calculateScore's +1 active-maintainers weight was inert; `isActive`
|
|
65
|
+
* (recent commit activity) is a real, already-computed proxy.
|
|
66
|
+
*
|
|
67
|
+
* `isResponsive` and `avgResponseDays` are deliberately NOT set here:
|
|
68
|
+
* `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
|
|
69
|
+
* (repo-health.ts), so deriving responsiveness from it would award +1 to
|
|
70
|
+
* every repo — a fake signal worse than the inert one. Real responsiveness
|
|
71
|
+
* needs an actual response-time measurement (extra API calls), deferred.
|
|
72
|
+
* `hasHostileComments` likewise stays a host-settable capability (it needs
|
|
73
|
+
* comment sentiment, out of scope). A failed health check is skipped so its
|
|
74
|
+
* neutral-default fields don't pollute the score.
|
|
75
|
+
*/
|
|
76
|
+
private updateRepoSignalsFromHealth;
|
|
51
77
|
/**
|
|
52
78
|
* Vet a single issue URL for claimability.
|
|
53
79
|
*/
|
|
@@ -83,15 +109,21 @@ export declare class OssScout implements ScoutStateReader {
|
|
|
83
109
|
getReposWithOpenPRs(): string[];
|
|
84
110
|
getStarredRepos(): string[];
|
|
85
111
|
getProjectCategories(): ProjectCategory[];
|
|
112
|
+
/** Configured GitHub username (used to classify own vs competing PRs, #166). */
|
|
113
|
+
getGitHubUsername(): string;
|
|
86
114
|
getRepoScore(repo: string): number | null;
|
|
87
115
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
116
|
+
* Number of the user's PRs closed without merge in this repo (#125).
|
|
117
|
+
* Prefers the tracked repo score; falls back to counting closedPRs so the
|
|
118
|
+
* scoring penalty works even before a score record exists.
|
|
119
|
+
*/
|
|
120
|
+
getClosedWithoutMergeCount(repo: string): number;
|
|
121
|
+
/**
|
|
122
|
+
* SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
|
|
123
|
+
* `null` when no `slmTriageModel` is configured — the vetter skips the SLM
|
|
124
|
+
* call entirely (#158).
|
|
90
125
|
*/
|
|
91
|
-
getSLMTriageConfig():
|
|
92
|
-
model: string;
|
|
93
|
-
host: string;
|
|
94
|
-
};
|
|
126
|
+
getSLMTriageConfig(): SLMConfig | null;
|
|
95
127
|
/** Get current preferences (read-only). */
|
|
96
128
|
getPreferences(): Readonly<ScoutPreferences>;
|
|
97
129
|
/** Get repo score record for a specific repository. */
|
|
@@ -110,6 +142,17 @@ export declare class OssScout implements ScoutStateReader {
|
|
|
110
142
|
* Open PRs signal active engagement even when nothing is merged yet.
|
|
111
143
|
*/
|
|
112
144
|
recordOpenPR(pr: OpenPRRecord): void;
|
|
145
|
+
/**
|
|
146
|
+
* Reconcile tracked open PRs against their current GitHub state (#164).
|
|
147
|
+
*
|
|
148
|
+
* `state.openPRs` was append-only — nothing transitioned an open PR to
|
|
149
|
+
* merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
|
|
150
|
+
* merged. This checks each open PR, records merges/closures (which updates
|
|
151
|
+
* the repo score), prunes resolved entries, and checkpoints. Cheaper than a
|
|
152
|
+
* full bootstrap, so a host can call it on daily startup. Transient errors
|
|
153
|
+
* leave the entry in place; auth/rate-limit failures propagate.
|
|
154
|
+
*/
|
|
155
|
+
syncOpenPRs(): Promise<SyncResult>;
|
|
113
156
|
/**
|
|
114
157
|
* Update repo score with observed signals.
|
|
115
158
|
*/
|
|
@@ -138,6 +181,12 @@ export declare class OssScout implements ScoutStateReader {
|
|
|
138
181
|
* Clear all saved results.
|
|
139
182
|
*/
|
|
140
183
|
clearResults(): void;
|
|
184
|
+
/**
|
|
185
|
+
* Record deletion tombstones (#117) so a later gist merge does not
|
|
186
|
+
* resurrect these URLs from another machine's copy. A re-add with a newer
|
|
187
|
+
* timestamp overrides the tombstone in mergeStates.
|
|
188
|
+
*/
|
|
189
|
+
private addTombstones;
|
|
141
190
|
/**
|
|
142
191
|
* Skip an issue — excludes it from future searches. Auto-culled after 90 days.
|
|
143
192
|
*/
|
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.";
|
|
@@ -93,8 +93,13 @@ function toGistOctokit(octokit) {
|
|
|
93
93
|
* ```
|
|
94
94
|
*/
|
|
95
95
|
export async function createScout(config) {
|
|
96
|
+
// Apply the host's log-level preference before any bootstrap chatter (#156).
|
|
97
|
+
if (config.logLevel !== undefined) {
|
|
98
|
+
setLogLevel(config.logLevel);
|
|
99
|
+
}
|
|
96
100
|
let state;
|
|
97
101
|
let gistStore = null;
|
|
102
|
+
let persistLocal = false;
|
|
98
103
|
if (config.persistence === "provided") {
|
|
99
104
|
state = config.initialState;
|
|
100
105
|
}
|
|
@@ -114,9 +119,15 @@ export async function createScout(config) {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
else {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// Default: local-file persistence. The previous else-branch silently
|
|
123
|
+
// created throwaway in-memory state, so a documented standalone scout
|
|
124
|
+
// (and the MCP server) read no preferences and persisted nothing while
|
|
125
|
+
// checkpoint() reported success (#116). Load the real state and save it
|
|
126
|
+
// on checkpoint.
|
|
127
|
+
state = loadLocalState();
|
|
128
|
+
persistLocal = true;
|
|
129
|
+
}
|
|
130
|
+
return new OssScout(config.githubToken, state, gistStore, { persistLocal });
|
|
120
131
|
}
|
|
121
132
|
/**
|
|
122
133
|
* Main oss-scout class. Provides search, vetting, and state management.
|
|
@@ -129,28 +140,57 @@ export class OssScout {
|
|
|
129
140
|
gistStore;
|
|
130
141
|
state;
|
|
131
142
|
dirty = false;
|
|
132
|
-
|
|
143
|
+
/** When true, checkpoint() also writes ~/.oss-scout/state.json. */
|
|
144
|
+
persistLocal;
|
|
145
|
+
constructor(githubToken, initialState, gistStore = null, opts = {}) {
|
|
133
146
|
this.githubToken = githubToken;
|
|
134
147
|
this.gistStore = gistStore;
|
|
135
148
|
this.state = initialState;
|
|
149
|
+
this.persistLocal = opts.persistLocal ?? false;
|
|
136
150
|
}
|
|
137
151
|
// ── Search ──────────────────────────────────────────────────────────
|
|
152
|
+
/**
|
|
153
|
+
* Drop stale disk-cache entries. Called at the top of every cache-burning
|
|
154
|
+
* entry point (search, features, vetList); without it ~/.oss-scout/cache
|
|
155
|
+
* grows without bound. evictStale never throws (fs errors degrade to warn).
|
|
156
|
+
*/
|
|
157
|
+
evictStaleCacheEntries() {
|
|
158
|
+
getHttpCache().evictStale();
|
|
159
|
+
}
|
|
138
160
|
/**
|
|
139
161
|
* Multi-strategy issue search. Returns scored, sorted candidates.
|
|
140
162
|
* Automatically culls expired skip entries and filters skipped issues.
|
|
141
163
|
*/
|
|
142
164
|
async search(options) {
|
|
165
|
+
this.evictStaleCacheEntries();
|
|
143
166
|
// Auto-cull expired skips before searching
|
|
144
167
|
this.cullExpiredSkips();
|
|
145
168
|
const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
|
|
146
169
|
const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
|
|
170
|
+
// Per-call flags override the persisted personalization defaults (#168).
|
|
171
|
+
// An empty preference array reads as "no boost" just like an absent flag.
|
|
172
|
+
const prefs = this.state.preferences;
|
|
173
|
+
const prefLangs = prefs.preferLanguages ?? [];
|
|
174
|
+
const prefRepos = prefs.preferRepos ?? [];
|
|
175
|
+
const preferLanguages = options?.preferLanguages ??
|
|
176
|
+
(prefLangs.length > 0 ? prefLangs : undefined);
|
|
177
|
+
const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : undefined);
|
|
178
|
+
const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
|
|
147
179
|
const { candidates, strategiesUsed } = await discovery.searchIssues({
|
|
148
180
|
maxResults: options?.maxResults,
|
|
149
181
|
strategies: options?.strategies,
|
|
150
182
|
skippedUrls,
|
|
151
|
-
preferLanguages
|
|
152
|
-
preferRepos
|
|
183
|
+
preferLanguages,
|
|
184
|
+
preferRepos,
|
|
185
|
+
diversityRatio,
|
|
186
|
+
interPhaseDelayMs: options?.interPhaseDelayMs,
|
|
187
|
+
broadPhaseDelayMs: options?.broadPhaseDelayMs,
|
|
153
188
|
});
|
|
189
|
+
// Feed the freshly observed maintainer-responsiveness signals back into the
|
|
190
|
+
// repo scores so the next search ranks responsive/active repos higher (#167).
|
|
191
|
+
for (const c of candidates) {
|
|
192
|
+
this.updateRepoSignalsFromHealth(c.projectHealth);
|
|
193
|
+
}
|
|
154
194
|
this.state.lastSearchAt = new Date().toISOString();
|
|
155
195
|
this.dirty = true;
|
|
156
196
|
return {
|
|
@@ -161,6 +201,28 @@ export class OssScout {
|
|
|
161
201
|
strategiesUsed,
|
|
162
202
|
};
|
|
163
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Populate the `hasActiveMaintainers` repo-score signal from a freshly
|
|
206
|
+
* computed projectHealth (#167). It was initialized false and never set, so
|
|
207
|
+
* calculateScore's +1 active-maintainers weight was inert; `isActive`
|
|
208
|
+
* (recent commit activity) is a real, already-computed proxy.
|
|
209
|
+
*
|
|
210
|
+
* `isResponsive` and `avgResponseDays` are deliberately NOT set here:
|
|
211
|
+
* `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
|
|
212
|
+
* (repo-health.ts), so deriving responsiveness from it would award +1 to
|
|
213
|
+
* every repo — a fake signal worse than the inert one. Real responsiveness
|
|
214
|
+
* needs an actual response-time measurement (extra API calls), deferred.
|
|
215
|
+
* `hasHostileComments` likewise stays a host-settable capability (it needs
|
|
216
|
+
* comment sentiment, out of scope). A failed health check is skipped so its
|
|
217
|
+
* neutral-default fields don't pollute the score.
|
|
218
|
+
*/
|
|
219
|
+
updateRepoSignalsFromHealth(health) {
|
|
220
|
+
if (health.checkFailed)
|
|
221
|
+
return;
|
|
222
|
+
this.updateRepoScore(health.repo, {
|
|
223
|
+
signals: { hasActiveMaintainers: health.isActive },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
164
226
|
/**
|
|
165
227
|
* Vet a single issue URL for claimability.
|
|
166
228
|
*/
|
|
@@ -183,6 +245,7 @@ export class OssScout {
|
|
|
183
245
|
* preferences and excluded repos/orgs.
|
|
184
246
|
*/
|
|
185
247
|
async features(options) {
|
|
248
|
+
this.evictStaleCacheEntries();
|
|
186
249
|
const count = options?.count ?? 10;
|
|
187
250
|
const octokit = getOctokit(this.githubToken);
|
|
188
251
|
const vetter = new IssueVetter(octokit, this);
|
|
@@ -217,6 +280,7 @@ export class OssScout {
|
|
|
217
280
|
* Optionally prunes unavailable issues from saved results.
|
|
218
281
|
*/
|
|
219
282
|
async vetList(options) {
|
|
283
|
+
this.evictStaleCacheEntries();
|
|
220
284
|
const saved = this.getSavedResults();
|
|
221
285
|
const concurrency = options?.concurrency ?? 5;
|
|
222
286
|
const results = [];
|
|
@@ -238,6 +302,7 @@ export class OssScout {
|
|
|
238
302
|
number: item.number,
|
|
239
303
|
title: item.title,
|
|
240
304
|
status: this.classifyVetResult(candidate),
|
|
305
|
+
ok: true,
|
|
241
306
|
recommendation: candidate.recommendation,
|
|
242
307
|
viabilityScore: candidate.viabilityScore,
|
|
243
308
|
});
|
|
@@ -255,6 +320,7 @@ export class OssScout {
|
|
|
255
320
|
number: item.number,
|
|
256
321
|
title: item.title,
|
|
257
322
|
status: isGone ? "closed" : "error",
|
|
323
|
+
ok: false,
|
|
258
324
|
errorMessage: errorMessage(error),
|
|
259
325
|
});
|
|
260
326
|
})
|
|
@@ -282,6 +348,33 @@ export class OssScout {
|
|
|
282
348
|
hasPR: results.filter((r) => r.status === "has_pr").length,
|
|
283
349
|
errors: results.filter((r) => r.status === "error").length,
|
|
284
350
|
};
|
|
351
|
+
// Claim-watch (#165): compare each result's current status to the status
|
|
352
|
+
// recorded on the saved result last time, then persist the new status so
|
|
353
|
+
// the next run can diff again. "error" is transient — never a transition
|
|
354
|
+
// target and never stored.
|
|
355
|
+
const prevStatus = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r.lastStatus]));
|
|
356
|
+
const transitions = results
|
|
357
|
+
.filter((r) => r.status !== "error")
|
|
358
|
+
.filter((r) => {
|
|
359
|
+
const prev = prevStatus.get(r.issueUrl);
|
|
360
|
+
return prev !== undefined && prev !== r.status;
|
|
361
|
+
})
|
|
362
|
+
.map((r) => ({
|
|
363
|
+
issueUrl: r.issueUrl,
|
|
364
|
+
repo: r.repo,
|
|
365
|
+
number: r.number,
|
|
366
|
+
from: prevStatus.get(r.issueUrl),
|
|
367
|
+
to: r.status,
|
|
368
|
+
}));
|
|
369
|
+
const currentStatus = new Map(results.map((r) => [r.issueUrl, r.status]));
|
|
370
|
+
for (const saved of this.state.savedResults ?? []) {
|
|
371
|
+
const status = currentStatus.get(saved.issueUrl);
|
|
372
|
+
if (status !== undefined && status !== "error") {
|
|
373
|
+
saved.lastStatus = status;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (results.length > 0)
|
|
377
|
+
this.dirty = true;
|
|
285
378
|
let prunedCount;
|
|
286
379
|
if (options?.prune) {
|
|
287
380
|
const unavailableUrls = new Set(results
|
|
@@ -290,11 +383,18 @@ export class OssScout {
|
|
|
290
383
|
const before = (this.state.savedResults ?? []).length;
|
|
291
384
|
this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
|
|
292
385
|
prunedCount = before - (this.state.savedResults?.length ?? 0);
|
|
386
|
+
if (prunedCount > 0)
|
|
387
|
+
this.addTombstones([...unavailableUrls]);
|
|
293
388
|
this.dirty = true;
|
|
294
389
|
}
|
|
295
|
-
return { results, summary, prunedCount };
|
|
390
|
+
return { results, summary, prunedCount, transitions };
|
|
296
391
|
}
|
|
297
392
|
classifyVetResult(candidate) {
|
|
393
|
+
// Closed wins over everything: GitHub returns 200 for closed issues, so
|
|
394
|
+
// the 404/410 catch path alone never saw them (#120). Candidates cached
|
|
395
|
+
// by older versions lack issueState and read as open.
|
|
396
|
+
if (candidate.issueState === "closed")
|
|
397
|
+
return "closed";
|
|
298
398
|
const checks = candidate.vettingResult.checks;
|
|
299
399
|
if (!checks.noExistingPR)
|
|
300
400
|
return "has_pr";
|
|
@@ -334,19 +434,35 @@ export class OssScout {
|
|
|
334
434
|
getProjectCategories() {
|
|
335
435
|
return this.state.preferences.projectCategories;
|
|
336
436
|
}
|
|
437
|
+
/** Configured GitHub username (used to classify own vs competing PRs, #166). */
|
|
438
|
+
getGitHubUsername() {
|
|
439
|
+
return this.state.preferences.githubUsername;
|
|
440
|
+
}
|
|
337
441
|
getRepoScore(repo) {
|
|
338
442
|
const score = this.state.repoScores[repo];
|
|
339
443
|
return score ? score.score : null;
|
|
340
444
|
}
|
|
341
445
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
446
|
+
* Number of the user's PRs closed without merge in this repo (#125).
|
|
447
|
+
* Prefers the tracked repo score; falls back to counting closedPRs so the
|
|
448
|
+
* scoring penalty works even before a score record exists.
|
|
449
|
+
*/
|
|
450
|
+
getClosedWithoutMergeCount(repo) {
|
|
451
|
+
const score = this.state.repoScores[repo];
|
|
452
|
+
if (score)
|
|
453
|
+
return score.closedWithoutMergeCount;
|
|
454
|
+
return (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
|
|
458
|
+
* `null` when no `slmTriageModel` is configured — the vetter skips the SLM
|
|
459
|
+
* call entirely (#158).
|
|
344
460
|
*/
|
|
345
461
|
getSLMTriageConfig() {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
};
|
|
462
|
+
const model = this.state.preferences.slmTriageModel ?? "";
|
|
463
|
+
if (!model)
|
|
464
|
+
return null;
|
|
465
|
+
return { model, host: this.state.preferences.slmTriageHost ?? "" };
|
|
350
466
|
}
|
|
351
467
|
/** Get current preferences (read-only). */
|
|
352
468
|
getPreferences() {
|
|
@@ -401,6 +517,78 @@ export class OssScout {
|
|
|
401
517
|
];
|
|
402
518
|
this.dirty = true;
|
|
403
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Reconcile tracked open PRs against their current GitHub state (#164).
|
|
522
|
+
*
|
|
523
|
+
* `state.openPRs` was append-only — nothing transitioned an open PR to
|
|
524
|
+
* merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
|
|
525
|
+
* merged. This checks each open PR, records merges/closures (which updates
|
|
526
|
+
* the repo score), prunes resolved entries, and checkpoints. Cheaper than a
|
|
527
|
+
* full bootstrap, so a host can call it on daily startup. Transient errors
|
|
528
|
+
* leave the entry in place; auth/rate-limit failures propagate.
|
|
529
|
+
*/
|
|
530
|
+
async syncOpenPRs() {
|
|
531
|
+
const octokit = getOctokit(this.githubToken);
|
|
532
|
+
const open = this.state.openPRs ?? [];
|
|
533
|
+
const result = {
|
|
534
|
+
checked: open.length,
|
|
535
|
+
merged: 0,
|
|
536
|
+
closed: 0,
|
|
537
|
+
stillOpen: 0,
|
|
538
|
+
errors: 0,
|
|
539
|
+
};
|
|
540
|
+
const remaining = [];
|
|
541
|
+
for (const pr of open) {
|
|
542
|
+
const parsed = parseGitHubUrl(pr.url);
|
|
543
|
+
if (!parsed || parsed.type !== "pull") {
|
|
544
|
+
remaining.push(pr);
|
|
545
|
+
result.errors++;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const repoFullName = `${parsed.owner}/${parsed.repo}`;
|
|
549
|
+
try {
|
|
550
|
+
const { data } = await octokit.pulls.get({
|
|
551
|
+
owner: parsed.owner,
|
|
552
|
+
repo: parsed.repo,
|
|
553
|
+
pull_number: parsed.number,
|
|
554
|
+
});
|
|
555
|
+
if (data.merged) {
|
|
556
|
+
this.recordMergedPR({
|
|
557
|
+
url: pr.url,
|
|
558
|
+
title: pr.title,
|
|
559
|
+
mergedAt: data.merged_at ?? new Date().toISOString(),
|
|
560
|
+
repo: repoFullName,
|
|
561
|
+
});
|
|
562
|
+
result.merged++;
|
|
563
|
+
}
|
|
564
|
+
else if (data.state === "closed") {
|
|
565
|
+
this.recordClosedPR({
|
|
566
|
+
url: pr.url,
|
|
567
|
+
title: pr.title,
|
|
568
|
+
closedAt: data.closed_at ?? new Date().toISOString(),
|
|
569
|
+
repo: repoFullName,
|
|
570
|
+
});
|
|
571
|
+
result.closed++;
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
remaining.push(pr);
|
|
575
|
+
result.stillOpen++;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
// Auth/rate-limit aborts the whole sync; a transient/404 leaves the
|
|
580
|
+
// entry untouched so a later sync can retry rather than losing it.
|
|
581
|
+
rethrowIfFatal(err);
|
|
582
|
+
warn("scout", `sync: could not check ${pr.url}: ${errorMessage(err)}`);
|
|
583
|
+
remaining.push(pr);
|
|
584
|
+
result.errors++;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.state.openPRs = remaining;
|
|
588
|
+
this.dirty = true;
|
|
589
|
+
await this.checkpoint();
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
404
592
|
/**
|
|
405
593
|
* Update repo score with observed signals.
|
|
406
594
|
*/
|
|
@@ -436,6 +624,9 @@ export class OssScout {
|
|
|
436
624
|
*/
|
|
437
625
|
updatePreferences(updates) {
|
|
438
626
|
this.state.preferences = { ...this.state.preferences, ...updates };
|
|
627
|
+
// Stamp so the gist merge keeps the fresher preferences instead of
|
|
628
|
+
// always taking the remote copy (#117).
|
|
629
|
+
this.state.preferencesUpdatedAt = new Date().toISOString();
|
|
439
630
|
this.dirty = true;
|
|
440
631
|
}
|
|
441
632
|
/**
|
|
@@ -485,9 +676,25 @@ export class OssScout {
|
|
|
485
676
|
* Clear all saved results.
|
|
486
677
|
*/
|
|
487
678
|
clearResults() {
|
|
679
|
+
this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
|
|
488
680
|
this.state.savedResults = [];
|
|
489
681
|
this.dirty = true;
|
|
490
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* Record deletion tombstones (#117) so a later gist merge does not
|
|
685
|
+
* resurrect these URLs from another machine's copy. A re-add with a newer
|
|
686
|
+
* timestamp overrides the tombstone in mergeStates.
|
|
687
|
+
*/
|
|
688
|
+
addTombstones(urls) {
|
|
689
|
+
if (urls.length === 0)
|
|
690
|
+
return;
|
|
691
|
+
const removedAt = new Date().toISOString();
|
|
692
|
+
const existing = this.state.tombstones ?? [];
|
|
693
|
+
const byUrl = new Map(existing.map((t) => [t.url, t]));
|
|
694
|
+
for (const url of urls)
|
|
695
|
+
byUrl.set(url, { url, removedAt });
|
|
696
|
+
this.state.tombstones = [...byUrl.values()];
|
|
697
|
+
}
|
|
491
698
|
// ── Skip List ───────────────────────────────────────────────────────
|
|
492
699
|
/**
|
|
493
700
|
* Skip an issue — excludes it from future searches. Auto-culled after 90 days.
|
|
@@ -506,7 +713,10 @@ export class OssScout {
|
|
|
506
713
|
skippedAt: new Date().toISOString(),
|
|
507
714
|
},
|
|
508
715
|
];
|
|
509
|
-
// Also remove from saved results if present
|
|
716
|
+
// Also remove from saved results if present. No tombstone needed: the
|
|
717
|
+
// skip entry itself is the durable record, and mergeStates reconciles
|
|
718
|
+
// saved results against the skip list so a merge can't resurrect a
|
|
719
|
+
// skipped URL into the saved list (#117).
|
|
510
720
|
if (this.state.savedResults) {
|
|
511
721
|
this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url);
|
|
512
722
|
}
|
|
@@ -522,13 +732,17 @@ export class OssScout {
|
|
|
522
732
|
* Remove a specific issue from the skip list.
|
|
523
733
|
*/
|
|
524
734
|
unskipIssue(url) {
|
|
735
|
+
const before = (this.state.skippedIssues ?? []).length;
|
|
525
736
|
this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url);
|
|
737
|
+
if (this.state.skippedIssues.length < before)
|
|
738
|
+
this.addTombstones([url]);
|
|
526
739
|
this.dirty = true;
|
|
527
740
|
}
|
|
528
741
|
/**
|
|
529
742
|
* Clear all skipped issues.
|
|
530
743
|
*/
|
|
531
744
|
clearSkippedIssues() {
|
|
745
|
+
this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
|
|
532
746
|
this.state.skippedIssues = [];
|
|
533
747
|
this.dirty = true;
|
|
534
748
|
}
|
|
@@ -572,6 +786,17 @@ export class OssScout {
|
|
|
572
786
|
if (!ok)
|
|
573
787
|
return false;
|
|
574
788
|
}
|
|
789
|
+
if (this.persistLocal) {
|
|
790
|
+
// Honest persistence: in local mode the previous no-op return true
|
|
791
|
+
// claimed success while saving nothing (#116). A failed write keeps
|
|
792
|
+
// the dirty flag and reports failure.
|
|
793
|
+
try {
|
|
794
|
+
saveLocalState(this.state);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
575
800
|
this.dirty = false;
|
|
576
801
|
return true;
|
|
577
802
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oss-scout/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.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": {
|