@oss-scout/core 0.7.1 → 0.9.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 +57 -51
- package/dist/cli.js +90 -1
- package/dist/commands/config.js +14 -0
- package/dist/commands/features.d.ts +57 -0
- package/dist/commands/features.js +76 -0
- package/dist/commands/search.d.ts +13 -0
- package/dist/commands/search.js +10 -0
- package/dist/core/bootstrap.js +2 -0
- package/dist/core/feature-discovery.d.ts +158 -0
- package/dist/core/feature-discovery.js +380 -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-eligibility.js +5 -0
- package/dist/core/issue-scoring.d.ts +16 -0
- package/dist/core/issue-scoring.js +13 -0
- package/dist/core/issue-vetting.d.ts +30 -1
- package/dist/core/issue-vetting.js +29 -3
- package/dist/core/linked-pr.d.ts +18 -0
- package/dist/core/linked-pr.js +25 -0
- package/dist/core/repo-health.js +3 -0
- package/dist/core/roadmap.d.ts +38 -0
- package/dist/core/roadmap.js +131 -0
- package/dist/core/schemas.d.ts +20 -0
- package/dist/core/schemas.js +20 -0
- package/dist/core/search-phases.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +8 -2
- package/dist/scout.d.ts +25 -2
- package/dist/scout.js +83 -4
- package/package.json +1 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature Discovery — orchestrates `scout features` mode: surfaces
|
|
3
|
+
* feature-scoped contribution opportunities in repos where the user has
|
|
4
|
+
* 3+ merged PRs, ranked into separate "quick wins" and "bigger bets" buckets.
|
|
5
|
+
*
|
|
6
|
+
* Reuses existing infrastructure:
|
|
7
|
+
* - issue-vetting.ts — per-issue vetting + scoring (with featureSignals)
|
|
8
|
+
* - issue-scoring.ts — viability score (existing weights + feature bonuses)
|
|
9
|
+
* - http-cache.ts — response cache
|
|
10
|
+
* - errors.ts — auth/rate-limit propagation
|
|
11
|
+
*
|
|
12
|
+
* No state singletons — anchor repos are resolved from RepoScore[] passed in.
|
|
13
|
+
*/
|
|
14
|
+
import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
|
|
15
|
+
import { warn } from "./logger.js";
|
|
16
|
+
import { sleep } from "./utils.js";
|
|
17
|
+
import { fetchRoadmapIssueRefs } from "./roadmap.js";
|
|
18
|
+
const MODULE = "feature-discovery";
|
|
19
|
+
/** Delay between per-repo issue lists, mirroring search-phases.INTER_QUERY_DELAY_MS. */
|
|
20
|
+
const INTER_REPO_DELAY_MS = 2000;
|
|
21
|
+
/** Minimum viability score for a feature candidate to surface — same as scout search. */
|
|
22
|
+
const MIN_VIABILITY_SCORE = 40;
|
|
23
|
+
/** Default minimum merged-PR count for a repo to qualify as an anchor. */
|
|
24
|
+
export const ANCHOR_THRESHOLD = 3;
|
|
25
|
+
/** Default quick-wins / bigger-bets split ratio (60/40). */
|
|
26
|
+
export const DEFAULT_SPLIT_RATIO = 0.6;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve anchor repos: those with mergedPRCount >= threshold (default 3),
|
|
29
|
+
* sorted by mergedPRCount descending. ScoutState stores repoScores as a
|
|
30
|
+
* Record<string, RepoScore>, so we read its values.
|
|
31
|
+
*
|
|
32
|
+
* @param threshold Override minimum merged-PR count (#98).
|
|
33
|
+
*/
|
|
34
|
+
export function resolveAnchorRepos(repoScores, threshold = ANCHOR_THRESHOLD) {
|
|
35
|
+
return Object.values(repoScores)
|
|
36
|
+
.filter((rs) => rs.mergedPRCount >= threshold)
|
|
37
|
+
.sort((a, b) => b.mergedPRCount - a.mergedPRCount)
|
|
38
|
+
.map((rs) => rs.repo);
|
|
39
|
+
}
|
|
40
|
+
/** Labels that promote an issue to the "bigger-bet" bucket. */
|
|
41
|
+
export const BIGGER_BET_LABELS = new Set([
|
|
42
|
+
"roadmap",
|
|
43
|
+
"accepted-rfc",
|
|
44
|
+
"proposal",
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Classify an issue into "quick-win" or "bigger-bet" based on
|
|
48
|
+
* maintainer-commitment signals (milestone presence, label set, ROADMAP.md
|
|
49
|
+
* membership). Roadmap membership (#95) is treated as an explicit
|
|
50
|
+
* maintainer commitment and forces the bigger-bet horizon.
|
|
51
|
+
*/
|
|
52
|
+
export function classifyHorizon(input) {
|
|
53
|
+
if (input.hasMilestone || input.isOnRoadmap)
|
|
54
|
+
return "bigger-bet";
|
|
55
|
+
for (const label of input.labels) {
|
|
56
|
+
if (BIGGER_BET_LABELS.has(label.toLowerCase()))
|
|
57
|
+
return "bigger-bet";
|
|
58
|
+
}
|
|
59
|
+
return "quick-win";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Split feature candidates into two buckets respecting a configurable
|
|
63
|
+
* quick-wins / bigger-bets ratio (default 60/40). If either bucket is
|
|
64
|
+
* short, redirect the deficit to the other bucket. Each bucket is
|
|
65
|
+
* sorted by viabilityScore descending.
|
|
66
|
+
*
|
|
67
|
+
* @param ratio Fraction (0..1) of `count` to allocate to quick wins (#99).
|
|
68
|
+
*/
|
|
69
|
+
export function splitByHorizon(candidates, count, ratio = DEFAULT_SPLIT_RATIO) {
|
|
70
|
+
const allQuick = candidates
|
|
71
|
+
.filter((c) => c.horizon === "quick-win")
|
|
72
|
+
.sort((a, b) => b.viabilityScore - a.viabilityScore);
|
|
73
|
+
const allBigger = candidates
|
|
74
|
+
.filter((c) => c.horizon === "bigger-bet")
|
|
75
|
+
.sort((a, b) => b.viabilityScore - a.viabilityScore);
|
|
76
|
+
const targetQuick = Math.round(count * ratio);
|
|
77
|
+
const targetBigger = count - targetQuick;
|
|
78
|
+
const quickTaken = Math.min(allQuick.length, targetQuick);
|
|
79
|
+
const biggerTaken = Math.min(allBigger.length, targetBigger);
|
|
80
|
+
// Redirect deficits.
|
|
81
|
+
let quickFinal = quickTaken;
|
|
82
|
+
let biggerFinal = biggerTaken;
|
|
83
|
+
const quickDeficit = targetQuick - quickTaken;
|
|
84
|
+
const biggerDeficit = targetBigger - biggerTaken;
|
|
85
|
+
if (quickDeficit > 0) {
|
|
86
|
+
biggerFinal = Math.min(allBigger.length, biggerFinal + quickDeficit);
|
|
87
|
+
}
|
|
88
|
+
if (biggerDeficit > 0) {
|
|
89
|
+
quickFinal = Math.min(allQuick.length, quickFinal + biggerDeficit);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
quickWins: allQuick.slice(0, quickFinal),
|
|
93
|
+
biggerBets: allBigger.slice(0, biggerFinal),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Feature labels used to filter issues. Any-of match. */
|
|
97
|
+
export const FEATURE_LABELS = [
|
|
98
|
+
"enhancement",
|
|
99
|
+
"feature",
|
|
100
|
+
"feature-request",
|
|
101
|
+
"proposal",
|
|
102
|
+
"roadmap",
|
|
103
|
+
"accepted-rfc",
|
|
104
|
+
];
|
|
105
|
+
/** Labels excluded from feature-mode results (overlap with `scout` territory). */
|
|
106
|
+
export const FEATURE_EXCLUSION_LABELS = new Set([
|
|
107
|
+
"good first issue",
|
|
108
|
+
"bug",
|
|
109
|
+
"documentation",
|
|
110
|
+
]);
|
|
111
|
+
/**
|
|
112
|
+
* Labels that signal "the maintainer wants outside contributions". When any
|
|
113
|
+
* is present, combined with no linked PR and an issue age >= 60 days, the
|
|
114
|
+
* issue is treated as wontfix-no-contributor (#96).
|
|
115
|
+
*/
|
|
116
|
+
export const WONTFIX_NO_CONTRIBUTOR_LABELS = new Set([
|
|
117
|
+
"help wanted",
|
|
118
|
+
"contributions welcome",
|
|
119
|
+
"up-for-grabs",
|
|
120
|
+
"bounty",
|
|
121
|
+
"pinned",
|
|
122
|
+
"unmaintained",
|
|
123
|
+
]);
|
|
124
|
+
/** Minimum days an issue must be open to qualify as wontfix-no-contributor. */
|
|
125
|
+
export const WONTFIX_MIN_AGE_DAYS = 60;
|
|
126
|
+
/**
|
|
127
|
+
* Pure detector for the "wontfix because no contributor stepped up" pattern (#96).
|
|
128
|
+
*
|
|
129
|
+
* True when:
|
|
130
|
+
* - issue carries any of WONTFIX_NO_CONTRIBUTOR_LABELS, AND
|
|
131
|
+
* - issue has been open at least `minAgeDays` days (default 60)
|
|
132
|
+
*
|
|
133
|
+
* The orchestrator already filters out assigned issues before reaching the
|
|
134
|
+
* vetter. Linked-PR cases are deliberately not gated here: the existing
|
|
135
|
+
* -30 viability penalty for `hasExistingPR` already discounts those, and
|
|
136
|
+
* checking `hasLinkedPR` would require deferring scoring until after vet,
|
|
137
|
+
* doubling the work for a marginally cleaner signal.
|
|
138
|
+
*/
|
|
139
|
+
export function detectWontfixNoContributor(input) {
|
|
140
|
+
const matched = input.labels.some((l) => WONTFIX_NO_CONTRIBUTOR_LABELS.has(l.toLowerCase()));
|
|
141
|
+
if (!matched)
|
|
142
|
+
return false;
|
|
143
|
+
const created = new Date(input.createdAt);
|
|
144
|
+
if (Number.isNaN(created.getTime()))
|
|
145
|
+
return false;
|
|
146
|
+
const now = input.now ?? new Date();
|
|
147
|
+
const ageDays = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
148
|
+
return ageDays >= (input.minAgeDays ?? WONTFIX_MIN_AGE_DAYS);
|
|
149
|
+
}
|
|
150
|
+
export const NO_ANCHORS_MESSAGE = "No anchor repos yet (need 3+ merged PRs in a repo). Try `scout search` to build relationships first.";
|
|
151
|
+
export const NO_RESULTS_MESSAGE = "No open feature opportunities in your anchor repos right now. Check back next week, or try `scout search` for fix-mode work.";
|
|
152
|
+
function extractLabels(item) {
|
|
153
|
+
if (!Array.isArray(item.labels))
|
|
154
|
+
return [];
|
|
155
|
+
return item.labels
|
|
156
|
+
.map((l) => (typeof l === "string" ? l : l?.name))
|
|
157
|
+
.filter((s) => typeof s === "string");
|
|
158
|
+
}
|
|
159
|
+
function isFeatureIssue(item) {
|
|
160
|
+
const labels = extractLabels(item).map((l) => l.toLowerCase());
|
|
161
|
+
if (labels.length === 0)
|
|
162
|
+
return false;
|
|
163
|
+
if (labels.some((l) => FEATURE_EXCLUSION_LABELS.has(l)))
|
|
164
|
+
return false;
|
|
165
|
+
return labels.some((l) => FEATURE_LABELS.includes(l));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Orchestrate `scout features`: anchor resolution → per-repo issue listing
|
|
169
|
+
* → feature-signal extraction → vetting → horizon classification → bucket split.
|
|
170
|
+
*
|
|
171
|
+
* Returns separate "quick wins" and "bigger bets" buckets per the 60/40 target,
|
|
172
|
+
* with a human-friendly message when no anchors qualify or no candidates pass
|
|
173
|
+
* the viability threshold.
|
|
174
|
+
*
|
|
175
|
+
* Auth (401) and rate-limit errors propagate. Per-repo and per-issue failures
|
|
176
|
+
* degrade gracefully via `warn`.
|
|
177
|
+
*/
|
|
178
|
+
export async function discoverFeatures(opts) {
|
|
179
|
+
const anchorRepos = resolveAnchorRepos(opts.repoScores, opts.anchorThreshold);
|
|
180
|
+
if (anchorRepos.length === 0) {
|
|
181
|
+
return {
|
|
182
|
+
quickWins: [],
|
|
183
|
+
biggerBets: [],
|
|
184
|
+
anchorRepos: [],
|
|
185
|
+
message: NO_ANCHORS_MESSAGE,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const candidates = [];
|
|
189
|
+
for (let i = 0; i < anchorRepos.length; i++) {
|
|
190
|
+
if (i > 0)
|
|
191
|
+
await sleep(INTER_REPO_DELAY_MS);
|
|
192
|
+
const [owner, repo] = anchorRepos[i].split("/");
|
|
193
|
+
// Issues list and roadmap fetch run in parallel — roadmap scraping (#95)
|
|
194
|
+
// adds at most one extra GET per anchor repo and the result is reused
|
|
195
|
+
// across every issue in this loop iteration.
|
|
196
|
+
let response;
|
|
197
|
+
let roadmapRefs;
|
|
198
|
+
try {
|
|
199
|
+
const [listResp, refs] = await Promise.all([
|
|
200
|
+
opts.octokit.issues.listForRepo({
|
|
201
|
+
owner,
|
|
202
|
+
repo,
|
|
203
|
+
state: "open",
|
|
204
|
+
sort: "updated",
|
|
205
|
+
direction: "desc",
|
|
206
|
+
per_page: 20,
|
|
207
|
+
}),
|
|
208
|
+
fetchRoadmapIssueRefs(opts.octokit, owner, repo),
|
|
209
|
+
]);
|
|
210
|
+
response = listResp;
|
|
211
|
+
roadmapRefs = refs;
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
215
|
+
throw err;
|
|
216
|
+
warn(MODULE, `failed to list issues for ${anchorRepos[i]}: ${errorMessage(err)}`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const items = response.data.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it));
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
const labels = extractLabels(item);
|
|
222
|
+
const hasMilestone = !!item.milestone;
|
|
223
|
+
const reactions = item.reactions?.total_count ?? 0;
|
|
224
|
+
const comments = item.comments ?? 0;
|
|
225
|
+
const wontfixNoContributor = item.created_at
|
|
226
|
+
? detectWontfixNoContributor({ labels, createdAt: item.created_at })
|
|
227
|
+
: false;
|
|
228
|
+
const onRoadmap = typeof item.number === "number" && roadmapRefs.has(item.number);
|
|
229
|
+
let candidate;
|
|
230
|
+
try {
|
|
231
|
+
candidate = await opts.vetter.vetIssue(item.html_url, {
|
|
232
|
+
featureSignals: {
|
|
233
|
+
reactions,
|
|
234
|
+
comments,
|
|
235
|
+
hasMilestone,
|
|
236
|
+
wontfixNoContributor,
|
|
237
|
+
onRoadmap,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
243
|
+
throw err;
|
|
244
|
+
warn(MODULE, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const horizon = classifyHorizon({
|
|
248
|
+
hasMilestone,
|
|
249
|
+
labels,
|
|
250
|
+
isOnRoadmap: onRoadmap,
|
|
251
|
+
});
|
|
252
|
+
candidates.push({ ...candidate, horizon });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Drop low-viability results — same threshold as scout search.
|
|
256
|
+
const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
|
|
257
|
+
const split = splitByHorizon(passing, opts.count, opts.splitRatio);
|
|
258
|
+
const total = split.quickWins.length + split.biggerBets.length;
|
|
259
|
+
return {
|
|
260
|
+
...split,
|
|
261
|
+
anchorRepos,
|
|
262
|
+
message: total === 0 ? NO_RESULTS_MESSAGE : null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// ── Broad / cross-repo mode (#100) ──────────────────────────────────────
|
|
266
|
+
export const NO_BROAD_RESULTS_MESSAGE = "No open feature opportunities matched your filters. Try widening your language preferences in `scout config`.";
|
|
267
|
+
const DEFAULT_BROAD_MAX_TO_VET = 30;
|
|
268
|
+
/**
|
|
269
|
+
* Build a GitHub Search query for cross-repo feature discovery.
|
|
270
|
+
*
|
|
271
|
+
* Exported separately from `discoverFeaturesBroad` so the query construction
|
|
272
|
+
* is independently testable without mocking the Search API.
|
|
273
|
+
*/
|
|
274
|
+
export function buildBroadFeatureSearchQuery(opts) {
|
|
275
|
+
const parts = ["is:issue", "is:open", "no:assignee"];
|
|
276
|
+
// Feature labels — any-of via parenthesized OR.
|
|
277
|
+
const labelClause = FEATURE_LABELS.map((l) => `label:"${l}"`).join(" OR ");
|
|
278
|
+
parts.push(`(${labelClause})`);
|
|
279
|
+
// Exclude labels that overlap with `scout` territory.
|
|
280
|
+
for (const excl of FEATURE_EXCLUSION_LABELS) {
|
|
281
|
+
parts.push(`-label:"${excl}"`);
|
|
282
|
+
}
|
|
283
|
+
// Languages — skip the filter when "any" is the only preference, since
|
|
284
|
+
// GitHub Search has no `language:any` operator.
|
|
285
|
+
const languages = (opts.languages ?? []).filter((l) => l && l.toLowerCase() !== "any");
|
|
286
|
+
if (languages.length > 0) {
|
|
287
|
+
const langClause = languages.map((l) => `language:${l}`).join(" OR ");
|
|
288
|
+
parts.push(`(${langClause})`);
|
|
289
|
+
}
|
|
290
|
+
// User exclusions.
|
|
291
|
+
for (const repo of opts.excludeRepos ?? []) {
|
|
292
|
+
parts.push(`-repo:${repo}`);
|
|
293
|
+
}
|
|
294
|
+
for (const org of opts.excludeOrgs ?? []) {
|
|
295
|
+
parts.push(`-user:${org}`);
|
|
296
|
+
}
|
|
297
|
+
return parts.join(" ");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Orchestrate broad / cross-repo feature discovery (#100). Bypasses anchor
|
|
301
|
+
* resolution; runs a single GitHub Search API query for feature-labeled
|
|
302
|
+
* open issues across the entire ecosystem, filtered by the user's language
|
|
303
|
+
* preferences and excluded repos/orgs.
|
|
304
|
+
*
|
|
305
|
+
* Designed for first-touch contributors who haven't yet built repo
|
|
306
|
+
* relationships and so wouldn't qualify under the default `scout features`
|
|
307
|
+
* anchor-based path.
|
|
308
|
+
*
|
|
309
|
+
* Auth (401) and rate-limit errors propagate; per-issue vet failures
|
|
310
|
+
* degrade gracefully.
|
|
311
|
+
*/
|
|
312
|
+
export async function discoverFeaturesBroad(opts) {
|
|
313
|
+
const query = buildBroadFeatureSearchQuery({
|
|
314
|
+
languages: opts.languages,
|
|
315
|
+
excludeRepos: opts.excludeRepos,
|
|
316
|
+
excludeOrgs: opts.excludeOrgs,
|
|
317
|
+
});
|
|
318
|
+
const maxToVet = opts.maxToVet ?? DEFAULT_BROAD_MAX_TO_VET;
|
|
319
|
+
let items;
|
|
320
|
+
try {
|
|
321
|
+
const response = await opts.octokit.search.issuesAndPullRequests({
|
|
322
|
+
q: query,
|
|
323
|
+
sort: "interactions",
|
|
324
|
+
order: "desc",
|
|
325
|
+
per_page: maxToVet,
|
|
326
|
+
});
|
|
327
|
+
items = response.data.items.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it));
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
331
|
+
throw err;
|
|
332
|
+
warn(MODULE, `broad feature search failed: ${errorMessage(err)}`);
|
|
333
|
+
return {
|
|
334
|
+
quickWins: [],
|
|
335
|
+
biggerBets: [],
|
|
336
|
+
anchorRepos: [],
|
|
337
|
+
message: NO_BROAD_RESULTS_MESSAGE,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const candidates = [];
|
|
341
|
+
for (const item of items) {
|
|
342
|
+
const labels = extractLabels(item);
|
|
343
|
+
const hasMilestone = !!item.milestone;
|
|
344
|
+
const reactions = item.reactions?.total_count ?? 0;
|
|
345
|
+
const comments = item.comments ?? 0;
|
|
346
|
+
const wontfixNoContributor = item.created_at
|
|
347
|
+
? detectWontfixNoContributor({ labels, createdAt: item.created_at })
|
|
348
|
+
: false;
|
|
349
|
+
let candidate;
|
|
350
|
+
try {
|
|
351
|
+
candidate = await opts.vetter.vetIssue(item.html_url, {
|
|
352
|
+
featureSignals: {
|
|
353
|
+
reactions,
|
|
354
|
+
comments,
|
|
355
|
+
hasMilestone,
|
|
356
|
+
wontfixNoContributor,
|
|
357
|
+
// Roadmap scraping is per-repo and would require an extra fetch
|
|
358
|
+
// per unique repo in the broad result set — deliberately skipped
|
|
359
|
+
// here to keep the broad path cheap. Anchor mode keeps the bonus.
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
|
|
365
|
+
throw err;
|
|
366
|
+
warn(MODULE, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const horizon = classifyHorizon({ hasMilestone, labels });
|
|
370
|
+
candidates.push({ ...candidate, horizon });
|
|
371
|
+
}
|
|
372
|
+
const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
|
|
373
|
+
const split = splitByHorizon(passing, opts.count, opts.splitRatio);
|
|
374
|
+
const total = split.quickWins.length + split.biggerBets.length;
|
|
375
|
+
return {
|
|
376
|
+
...split,
|
|
377
|
+
anchorRepos: [],
|
|
378
|
+
message: total === 0 ? NO_BROAD_RESULTS_MESSAGE : null,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -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 {
|
|
@@ -36,12 +36,17 @@ function buildLinkedPRFromTimelineEvent(e, context) {
|
|
|
36
36
|
warn(MODULE, `Cross-referenced PR #${issue.number} for ${ctx} missing html_url — skipping linkedPR metadata`);
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
|
+
// updatedAt is read directly from the timeline event's source.issue
|
|
40
|
+
// (issue.updated_at is exposed on the cross-reference payload), so no
|
|
41
|
+
// extra pulls.get round-trip is needed. Left undefined when absent —
|
|
42
|
+
// isLinkedPRStalled treats missing data as not-stalled.
|
|
39
43
|
return {
|
|
40
44
|
number: issue.number,
|
|
41
45
|
author,
|
|
42
46
|
state: issue.state === "closed" ? "closed" : "open",
|
|
43
47
|
merged: !!issue.pull_request?.merged_at,
|
|
44
48
|
url,
|
|
49
|
+
updatedAt: issue.updated_at,
|
|
45
50
|
};
|
|
46
51
|
}
|
|
47
52
|
const MODULE = "issue-eligibility";
|
|
@@ -23,6 +23,22 @@ export interface ViabilityScoreParams {
|
|
|
23
23
|
repoQualityBonus?: number;
|
|
24
24
|
/** True when the repo matches one of the user's preferred project categories. */
|
|
25
25
|
matchesPreferredCategory?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Optional feature-mode signals. When present, applies reaction (cap +10),
|
|
28
|
+
* comment-depth (+5 if >=5), milestone (+5), and wontfixNoContributor
|
|
29
|
+
* (+10) bonuses. When absent, scoring behavior is unchanged.
|
|
30
|
+
*
|
|
31
|
+
* Note: `onRoadmap` is forwarded for cache-key uniqueness but does NOT
|
|
32
|
+
* contribute to the viability score (#95) — roadmap membership influences
|
|
33
|
+
* horizon classification only, not score.
|
|
34
|
+
*/
|
|
35
|
+
featureSignals?: {
|
|
36
|
+
reactions: number;
|
|
37
|
+
comments: number;
|
|
38
|
+
hasMilestone: boolean;
|
|
39
|
+
onRoadmap?: boolean;
|
|
40
|
+
wontfixNoContributor?: boolean;
|
|
41
|
+
};
|
|
26
42
|
}
|
|
27
43
|
/**
|
|
28
44
|
* Calculate viability score for an issue (0-100 scale)
|
|
@@ -92,6 +92,19 @@ export function calculateViabilityScore(params) {
|
|
|
92
92
|
if (params.closedWithoutMergeCount > 0 && params.mergedPRCount === 0) {
|
|
93
93
|
score -= 15;
|
|
94
94
|
}
|
|
95
|
+
// Feature signals: reactions, comment depth, milestone, wontfix-no-contributor.
|
|
96
|
+
// Note: onRoadmap (#95) is intentionally NOT scored — roadmap membership is
|
|
97
|
+
// surfaced via the horizon classifier instead.
|
|
98
|
+
if (params.featureSignals) {
|
|
99
|
+
const fs = params.featureSignals;
|
|
100
|
+
score += Math.min(Math.floor(fs.reactions / 2), 10);
|
|
101
|
+
if (fs.comments >= 5)
|
|
102
|
+
score += 5;
|
|
103
|
+
if (fs.hasMilestone)
|
|
104
|
+
score += 5;
|
|
105
|
+
if (fs.wontfixNoContributor)
|
|
106
|
+
score += 10;
|
|
107
|
+
}
|
|
95
108
|
// Clamp to 0-100
|
|
96
109
|
return Math.max(0, Math.min(100, score));
|
|
97
110
|
}
|
|
@@ -8,6 +8,28 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Octokit } from "@octokit/rest";
|
|
10
10
|
import { type SearchPriority, type IssueCandidate, type ProjectCategory } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Feature-mode signals supplied by the caller (orchestrator) — the vetter
|
|
13
|
+
* does NOT extract these from the GitHub issue itself. When passed, they
|
|
14
|
+
* plumb through to `calculateViabilityScore` to apply reaction, comment-depth,
|
|
15
|
+
* milestone, roadmap, and wontfix-no-contributor bonuses.
|
|
16
|
+
*/
|
|
17
|
+
export type FeatureSignals = {
|
|
18
|
+
reactions: number;
|
|
19
|
+
comments: number;
|
|
20
|
+
hasMilestone: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Issue is referenced from the repo's ROADMAP.md. Strong maintainer-commitment
|
|
23
|
+
* signal — they've publicly committed to the work in a roadmap doc (#95).
|
|
24
|
+
*/
|
|
25
|
+
onRoadmap?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Issue exhibits "wontfix-no-contributor" pattern — labeled help-wanted /
|
|
28
|
+
* contributions-welcome / up-for-grabs / bounty, no linked PR, open >= 60
|
|
29
|
+
* days. Maintainer wants it; nobody has stepped up (#96).
|
|
30
|
+
*/
|
|
31
|
+
wontfixNoContributor?: boolean;
|
|
32
|
+
};
|
|
11
33
|
/**
|
|
12
34
|
* Read-only interface for accessing scout state during issue vetting.
|
|
13
35
|
* Implementations may be backed by gist persistence, in-memory state, etc.
|
|
@@ -40,8 +62,15 @@ export declare class IssueVetter {
|
|
|
40
62
|
/**
|
|
41
63
|
* Vet a specific issue — runs all checks and computes recommendation + viability score.
|
|
42
64
|
* Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
|
|
65
|
+
*
|
|
66
|
+
* `opts.featureSignals` are forwarded directly to scoring; the vetter does
|
|
67
|
+
* not derive them from the fetched issue. Cache key includes a digest of
|
|
68
|
+
* the signals so the same URL with different signals doesn't return a
|
|
69
|
+
* stale score.
|
|
43
70
|
*/
|
|
44
|
-
vetIssue(issueUrl: string
|
|
71
|
+
vetIssue(issueUrl: string, opts?: {
|
|
72
|
+
featureSignals?: FeatureSignals;
|
|
73
|
+
}): Promise<IssueCandidate>;
|
|
45
74
|
/**
|
|
46
75
|
* Vet multiple issues in parallel with concurrency limit
|
|
47
76
|
*/
|