@oss-scout/core 0.11.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 +78 -61
- package/dist/cli.js +401 -425
- 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.js +63 -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 +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 +2 -0
- package/dist/core/issue-discovery.js +44 -29
- 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 +15 -10
- package/dist/core/personalization.js +30 -22
- 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 +136 -38
- 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 -20
- 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,29 +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
|
|
153
|
-
diversityRatio
|
|
183
|
+
preferLanguages,
|
|
184
|
+
preferRepos,
|
|
185
|
+
diversityRatio,
|
|
186
|
+
interPhaseDelayMs: options?.interPhaseDelayMs,
|
|
187
|
+
broadPhaseDelayMs: options?.broadPhaseDelayMs,
|
|
154
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
|
+
}
|
|
155
194
|
this.state.lastSearchAt = new Date().toISOString();
|
|
156
195
|
this.dirty = true;
|
|
157
196
|
return {
|
|
@@ -162,6 +201,28 @@ export class OssScout {
|
|
|
162
201
|
strategiesUsed,
|
|
163
202
|
};
|
|
164
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
|
+
}
|
|
165
226
|
/**
|
|
166
227
|
* Vet a single issue URL for claimability.
|
|
167
228
|
*/
|
|
@@ -184,6 +245,7 @@ export class OssScout {
|
|
|
184
245
|
* preferences and excluded repos/orgs.
|
|
185
246
|
*/
|
|
186
247
|
async features(options) {
|
|
248
|
+
this.evictStaleCacheEntries();
|
|
187
249
|
const count = options?.count ?? 10;
|
|
188
250
|
const octokit = getOctokit(this.githubToken);
|
|
189
251
|
const vetter = new IssueVetter(octokit, this);
|
|
@@ -218,6 +280,7 @@ export class OssScout {
|
|
|
218
280
|
* Optionally prunes unavailable issues from saved results.
|
|
219
281
|
*/
|
|
220
282
|
async vetList(options) {
|
|
283
|
+
this.evictStaleCacheEntries();
|
|
221
284
|
const saved = this.getSavedResults();
|
|
222
285
|
const concurrency = options?.concurrency ?? 5;
|
|
223
286
|
const results = [];
|
|
@@ -239,6 +302,7 @@ export class OssScout {
|
|
|
239
302
|
number: item.number,
|
|
240
303
|
title: item.title,
|
|
241
304
|
status: this.classifyVetResult(candidate),
|
|
305
|
+
ok: true,
|
|
242
306
|
recommendation: candidate.recommendation,
|
|
243
307
|
viabilityScore: candidate.viabilityScore,
|
|
244
308
|
});
|
|
@@ -256,6 +320,7 @@ export class OssScout {
|
|
|
256
320
|
number: item.number,
|
|
257
321
|
title: item.title,
|
|
258
322
|
status: isGone ? "closed" : "error",
|
|
323
|
+
ok: false,
|
|
259
324
|
errorMessage: errorMessage(error),
|
|
260
325
|
});
|
|
261
326
|
})
|
|
@@ -283,6 +348,33 @@ export class OssScout {
|
|
|
283
348
|
hasPR: results.filter((r) => r.status === "has_pr").length,
|
|
284
349
|
errors: results.filter((r) => r.status === "error").length,
|
|
285
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;
|
|
286
378
|
let prunedCount;
|
|
287
379
|
if (options?.prune) {
|
|
288
380
|
const unavailableUrls = new Set(results
|
|
@@ -291,11 +383,18 @@ export class OssScout {
|
|
|
291
383
|
const before = (this.state.savedResults ?? []).length;
|
|
292
384
|
this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
|
|
293
385
|
prunedCount = before - (this.state.savedResults?.length ?? 0);
|
|
386
|
+
if (prunedCount > 0)
|
|
387
|
+
this.addTombstones([...unavailableUrls]);
|
|
294
388
|
this.dirty = true;
|
|
295
389
|
}
|
|
296
|
-
return { results, summary, prunedCount };
|
|
390
|
+
return { results, summary, prunedCount, transitions };
|
|
297
391
|
}
|
|
298
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";
|
|
299
398
|
const checks = candidate.vettingResult.checks;
|
|
300
399
|
if (!checks.noExistingPR)
|
|
301
400
|
return "has_pr";
|
|
@@ -335,19 +434,35 @@ export class OssScout {
|
|
|
335
434
|
getProjectCategories() {
|
|
336
435
|
return this.state.preferences.projectCategories;
|
|
337
436
|
}
|
|
437
|
+
/** Configured GitHub username (used to classify own vs competing PRs, #166). */
|
|
438
|
+
getGitHubUsername() {
|
|
439
|
+
return this.state.preferences.githubUsername;
|
|
440
|
+
}
|
|
338
441
|
getRepoScore(repo) {
|
|
339
442
|
const score = this.state.repoScores[repo];
|
|
340
443
|
return score ? score.score : null;
|
|
341
444
|
}
|
|
342
445
|
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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).
|
|
345
460
|
*/
|
|
346
461
|
getSLMTriageConfig() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
};
|
|
462
|
+
const model = this.state.preferences.slmTriageModel ?? "";
|
|
463
|
+
if (!model)
|
|
464
|
+
return null;
|
|
465
|
+
return { model, host: this.state.preferences.slmTriageHost ?? "" };
|
|
351
466
|
}
|
|
352
467
|
/** Get current preferences (read-only). */
|
|
353
468
|
getPreferences() {
|
|
@@ -402,6 +517,78 @@ export class OssScout {
|
|
|
402
517
|
];
|
|
403
518
|
this.dirty = true;
|
|
404
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
|
+
}
|
|
405
592
|
/**
|
|
406
593
|
* Update repo score with observed signals.
|
|
407
594
|
*/
|
|
@@ -437,6 +624,9 @@ export class OssScout {
|
|
|
437
624
|
*/
|
|
438
625
|
updatePreferences(updates) {
|
|
439
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();
|
|
440
630
|
this.dirty = true;
|
|
441
631
|
}
|
|
442
632
|
/**
|
|
@@ -486,9 +676,25 @@ export class OssScout {
|
|
|
486
676
|
* Clear all saved results.
|
|
487
677
|
*/
|
|
488
678
|
clearResults() {
|
|
679
|
+
this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
|
|
489
680
|
this.state.savedResults = [];
|
|
490
681
|
this.dirty = true;
|
|
491
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
|
+
}
|
|
492
698
|
// ── Skip List ───────────────────────────────────────────────────────
|
|
493
699
|
/**
|
|
494
700
|
* Skip an issue — excludes it from future searches. Auto-culled after 90 days.
|
|
@@ -507,7 +713,10 @@ export class OssScout {
|
|
|
507
713
|
skippedAt: new Date().toISOString(),
|
|
508
714
|
},
|
|
509
715
|
];
|
|
510
|
-
// 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).
|
|
511
720
|
if (this.state.savedResults) {
|
|
512
721
|
this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url);
|
|
513
722
|
}
|
|
@@ -523,13 +732,17 @@ export class OssScout {
|
|
|
523
732
|
* Remove a specific issue from the skip list.
|
|
524
733
|
*/
|
|
525
734
|
unskipIssue(url) {
|
|
735
|
+
const before = (this.state.skippedIssues ?? []).length;
|
|
526
736
|
this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url);
|
|
737
|
+
if (this.state.skippedIssues.length < before)
|
|
738
|
+
this.addTombstones([url]);
|
|
527
739
|
this.dirty = true;
|
|
528
740
|
}
|
|
529
741
|
/**
|
|
530
742
|
* Clear all skipped issues.
|
|
531
743
|
*/
|
|
532
744
|
clearSkippedIssues() {
|
|
745
|
+
this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
|
|
533
746
|
this.state.skippedIssues = [];
|
|
534
747
|
this.dirty = true;
|
|
535
748
|
}
|
|
@@ -573,6 +786,17 @@ export class OssScout {
|
|
|
573
786
|
if (!ok)
|
|
574
787
|
return false;
|
|
575
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
|
+
}
|
|
576
800
|
this.dirty = false;
|
|
577
801
|
return true;
|
|
578
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": {
|