@oss-scout/core 0.8.0 → 0.9.1

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.
@@ -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
+ }
@@ -21,7 +21,7 @@ import { debug, info, warn } from "./logger.js";
21
21
  import { isDocOnlyIssue, applyPerRepoCap, } from "./issue-filtering.js";
22
22
  import { IssueVetter } from "./issue-vetting.js";
23
23
  import { getTopicsForCategories } from "./category-mapping.js";
24
- import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, fetchIssuesFromMaintainedRepos, filterVetAndScore, fetchIssuesFromKnownRepos, searchWithChunkedLabels, } from "./search-phases.js";
24
+ import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, fetchIssuesFromMaintainedRepos, filterVetAndScore, fetchIssuesFromKnownRepos, searchAcrossLanguagesAndLabels, } from "./search-phases.js";
25
25
  const MODULE = "issue-discovery";
26
26
  /** If remaining search quota is below this, skip heavy phases (2, 3). */
27
27
  const LOW_BUDGET_THRESHOLD = 20;
@@ -83,7 +83,7 @@ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssue
83
83
  };
84
84
  }
85
85
  /** Phase 2: General label-filtered search with multi-tier interleaving. */
86
- async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQualifiers, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
86
+ async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
87
87
  info(MODULE, "Phase 2: General issue search...");
88
88
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
89
89
  // Build per-tier label groups. Multi-tier when 2+ scopes; single-tier otherwise.
@@ -112,7 +112,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQual
112
112
  let rateLimitHit = false;
113
113
  for (const { tier, tierLabels } of tierLabelGroups) {
114
114
  try {
115
- const allItems = await searchWithChunkedLabels(octokit, tierLabels, 0, (labelQ) => `${baseQualifiers} ${labelQ}`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
115
+ const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
116
116
  info(MODULE, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
117
117
  const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
118
118
  tierResults.push(tierCandidates);
@@ -337,9 +337,6 @@ export class IssueDiscovery {
337
337
  const langQuery = isAnyLanguage
338
338
  ? ""
339
339
  : languages.map((l) => `language:${l}`).join(" ");
340
- const baseQualifiers = `is:issue is:open ${langQuery} no:assignee`
341
- .replace(/ +/g, " ")
342
- .trim();
343
340
  // Build reusable filter
344
341
  const aiBlocklisted = new Set(config.aiPolicyBlocklist);
345
342
  if (aiBlocklisted.size > 0) {
@@ -427,7 +424,7 @@ export class IssueDiscovery {
427
424
  info(MODULE, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
428
425
  }
429
426
  const remaining = maxResults - allCandidates.length;
430
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
427
+ const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
431
428
  allCandidates.push(...result.candidates);
432
429
  phaseErrors["2"] = result.error;
433
430
  if (result.rateLimitHit)
@@ -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): Promise<IssueCandidate>;
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
  */
@@ -31,11 +31,19 @@ export class IssueVetter {
31
31
  /**
32
32
  * Vet a specific issue — runs all checks and computes recommendation + viability score.
33
33
  * Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
34
+ *
35
+ * `opts.featureSignals` are forwarded directly to scoring; the vetter does
36
+ * not derive them from the fetched issue. Cache key includes a digest of
37
+ * the signals so the same URL with different signals doesn't return a
38
+ * stale score.
34
39
  */
35
- async vetIssue(issueUrl) {
40
+ async vetIssue(issueUrl, opts) {
36
41
  // Check vetting cache first — avoids ~6+ API calls per issue
37
42
  const cache = getHttpCache();
38
- const cacheKey = `vet:${issueUrl}`;
43
+ const sigKey = opts?.featureSignals
44
+ ? `:r${opts.featureSignals.reactions}c${opts.featureSignals.comments}m${opts.featureSignals.hasMilestone ? 1 : 0}o${opts.featureSignals.onRoadmap ? 1 : 0}w${opts.featureSignals.wontfixNoContributor ? 1 : 0}`
45
+ : "";
46
+ const cacheKey = `vet:${issueUrl}${sigKey}`;
39
47
  const cached = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
40
48
  if (cached &&
41
49
  typeof cached === "object" &&
@@ -213,6 +221,7 @@ export class IssueVetter {
213
221
  orgHasMergedPRs,
214
222
  repoQualityBonus,
215
223
  matchesPreferredCategory: matchesCategory,
224
+ featureSignals: opts?.featureSignals,
216
225
  });
217
226
  const starredRepos = this.stateReader.getStarredRepos();
218
227
  let searchPriority = "normal";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Helpers for reasoning about linked PRs surfaced via the issue timeline.
3
+ *
4
+ * Currently scoped to detecting "stalled" PRs — open PRs that have not been
5
+ * updated in the last `STALLED_PR_THRESHOLD_DAYS` days. Stalled linked PRs
6
+ * are surfaced as revive opportunities (#97). Note: this module does NOT
7
+ * change scoring; the existing -30 penalty on issues with linked PRs is
8
+ * preserved. This is annotation-only.
9
+ */
10
+ import type { LinkedPR } from "./schemas.js";
11
+ /** Days of inactivity that classify a linked PR as "stalled". */
12
+ export declare const STALLED_PR_THRESHOLD_DAYS = 30;
13
+ /**
14
+ * Determine whether a linked PR is stalled (open + not updated in the
15
+ * last STALLED_PR_THRESHOLD_DAYS days). Returns false when the PR is
16
+ * closed/merged, when updatedAt is missing, or when the threshold isn't met.
17
+ */
18
+ export declare function isLinkedPRStalled(linkedPR: LinkedPR | null | undefined, now?: Date, thresholdDays?: number): boolean;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Helpers for reasoning about linked PRs surfaced via the issue timeline.
3
+ *
4
+ * Currently scoped to detecting "stalled" PRs — open PRs that have not been
5
+ * updated in the last `STALLED_PR_THRESHOLD_DAYS` days. Stalled linked PRs
6
+ * are surfaced as revive opportunities (#97). Note: this module does NOT
7
+ * change scoring; the existing -30 penalty on issues with linked PRs is
8
+ * preserved. This is annotation-only.
9
+ */
10
+ /** Days of inactivity that classify a linked PR as "stalled". */
11
+ export const STALLED_PR_THRESHOLD_DAYS = 30;
12
+ /**
13
+ * Determine whether a linked PR is stalled (open + not updated in the
14
+ * last STALLED_PR_THRESHOLD_DAYS days). Returns false when the PR is
15
+ * closed/merged, when updatedAt is missing, or when the threshold isn't met.
16
+ */
17
+ export function isLinkedPRStalled(linkedPR, now = new Date(), thresholdDays = STALLED_PR_THRESHOLD_DAYS) {
18
+ if (!linkedPR || linkedPR.state !== "open" || !linkedPR.updatedAt)
19
+ return false;
20
+ const updated = new Date(linkedPR.updatedAt);
21
+ if (Number.isNaN(updated.getTime()))
22
+ return false;
23
+ const ageDays = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24);
24
+ return ageDays >= thresholdDays;
25
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Roadmap scraping (#95) — fetches a repo's ROADMAP.md (or variant) and
3
+ * extracts referenced GitHub issue numbers. Used by feature-discovery to
4
+ * apply an `onRoadmap` bonus when the maintainer has publicly committed
5
+ * to an issue in their roadmap doc.
6
+ *
7
+ * Cached for 1 hour per repo. 404s are cached as empty sets so repos
8
+ * without a roadmap don't get re-probed every run.
9
+ *
10
+ * Auth (401) and rate-limit errors propagate, matching the rest of the
11
+ * codebase's error strategy. Other errors degrade gracefully (warn + empty).
12
+ */
13
+ import type { Octokit } from "@octokit/rest";
14
+ /**
15
+ * Parse markdown content for issue number references.
16
+ *
17
+ * Extracts:
18
+ * - bare `#NNN` references that are not on a markdown heading line
19
+ * - `<owner>/<repo>#NNN` cross-repo refs that match the repo we're parsing for
20
+ * - `https://github.com/<owner>/<repo>/issues/NNN` URLs that match the
21
+ * repo we're parsing for (URLs to other repos are ignored)
22
+ *
23
+ * The match for cross-repo `owner/repo#N` and full URLs is intentional:
24
+ * scattered references to unrelated repos in a roadmap shouldn't bump
25
+ * scores in those repos.
26
+ */
27
+ export declare function parseRoadmapIssueRefs(content: string, owner: string, repo: string): Set<number>;
28
+ /**
29
+ * Fetch and parse the repo's ROADMAP.md, returning the set of referenced
30
+ * issue numbers. Returns an empty set if no roadmap is found.
31
+ *
32
+ * Probes ROADMAP_PATHS sequentially until a 200 response is received.
33
+ * Auth/rate-limit errors propagate; other errors are logged and degrade
34
+ * to an empty set.
35
+ */
36
+ export declare function fetchRoadmapIssueRefs(octokit: Octokit, owner: string, repo: string): Promise<Set<number>>;
37
+ /** Test-only: clear the in-memory cache. */
38
+ export declare function _clearRoadmapCacheForTests(): void;