@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.
@@ -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
- debug(MODULE, `Cached gist ID invalid: ${errorMessage(err)}`);
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 { gistId: "", state: fresh, created: false, degraded: true };
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): 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
  */