@oss-scout/core 1.0.0 → 1.2.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,60 @@
1
+ /**
2
+ * Human-readable (non-JSON) output formatters for the oss-scout CLI.
3
+ *
4
+ * Each renderer is a pure function that returns the exact multi-line string the
5
+ * CLI used to emit via a sequence of `console.log` calls. The caller does a
6
+ * single `console.log(renderX(...))`, which appends the one trailing newline
7
+ * that the final `console.log` in the old inline block produced.
8
+ *
9
+ * To stay byte-identical: every old `console.log(line)` becomes one entry in a
10
+ * lines array, a bare `console.log()` (blank line) becomes an empty entry, and
11
+ * the array is joined with "\n". The caller's own `console.log` supplies the
12
+ * last newline. STDERR output (the search rate-limit warning) is deliberately
13
+ * NOT folded in here — it stays a `console.error` in the caller.
14
+ */
15
+ import type { SearchOutput } from "../commands/search.js";
16
+ import type { FeaturesOutput } from "../commands/features.js";
17
+ import type { SavedCandidate } from "../core/schemas.js";
18
+ import type { VetListResult } from "../core/types.js";
19
+ import type { VetOutput } from "../commands/vet.js";
20
+ /** Emoji for a vetting recommendation, shared by the search and vet renderers. */
21
+ export declare function recommendationIcon(recommendation: "approve" | "skip" | "needs_review"): string;
22
+ /**
23
+ * Render the human-readable `search` output: the "Found N issue candidates"
24
+ * block with per-candidate icon, personalization and stalled tags, and the
25
+ * optional repoScore line. The trailing rate-limit warning is NOT included
26
+ * here; it goes to stderr in the caller.
27
+ */
28
+ export declare function renderSearch(results: SearchOutput): string;
29
+ /**
30
+ * Render the human-readable `features` output: the optional message, the
31
+ * "Feature opportunities" header, the anchor repos line, and the Quick wins /
32
+ * Bigger bets sections. Returns "" when there is nothing to print beyond an
33
+ * absent message (caller guards against logging a blank line).
34
+ */
35
+ export declare function renderFeatures(result: FeaturesOutput, options: {
36
+ broad?: boolean;
37
+ }): string;
38
+ /** The empty-state message printed by `results` when nothing is saved. */
39
+ export declare const RESULTS_EMPTY_MESSAGE = "\nNo saved results. Run `oss-scout search` to find issues.\n";
40
+ /**
41
+ * Render the human-readable `results` table: the "Saved results" header and a
42
+ * Score / Repo / Issue / Recommendation / Title row per saved candidate.
43
+ * Callers handle the empty state (RESULTS_EMPTY_MESSAGE) separately.
44
+ */
45
+ export declare function renderResults(results: SavedCandidate[]): string;
46
+ /** The empty-state message printed by `vet-list` when there is nothing to vet. */
47
+ export declare const VET_LIST_EMPTY_MESSAGE = "\nNo saved results to vet. Run `oss-scout search` first.\n";
48
+ /**
49
+ * Render the human-readable `vet-list` output: the "Vet-list results (N)"
50
+ * block with a per-row status icon, the "Changes since last check"
51
+ * transitions block, the summary line, and the optional pruned-count line.
52
+ * Callers handle the empty state (VET_LIST_EMPTY_MESSAGE) separately.
53
+ */
54
+ export declare function renderVetList(result: VetListResult): string;
55
+ /**
56
+ * Render the human-readable single-issue `vet` output: the recommendation
57
+ * header, the reasons to approve / skip, and the project-health block. The
58
+ * checkFailed branch (#158) is preserved exactly.
59
+ */
60
+ export declare function renderVet(result: VetOutput): string;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Human-readable (non-JSON) output formatters for the oss-scout CLI.
3
+ *
4
+ * Each renderer is a pure function that returns the exact multi-line string the
5
+ * CLI used to emit via a sequence of `console.log` calls. The caller does a
6
+ * single `console.log(renderX(...))`, which appends the one trailing newline
7
+ * that the final `console.log` in the old inline block produced.
8
+ *
9
+ * To stay byte-identical: every old `console.log(line)` becomes one entry in a
10
+ * lines array, a bare `console.log()` (blank line) becomes an empty entry, and
11
+ * the array is joined with "\n". The caller's own `console.log` supplies the
12
+ * last newline. STDERR output (the search rate-limit warning) is deliberately
13
+ * NOT folded in here — it stays a `console.error` in the caller.
14
+ */
15
+ /** Emoji for a vetting recommendation, shared by the search and vet renderers. */
16
+ export function recommendationIcon(recommendation) {
17
+ if (recommendation === "approve")
18
+ return "✅";
19
+ if (recommendation === "skip")
20
+ return "❌";
21
+ return "⚠️";
22
+ }
23
+ /**
24
+ * Render the human-readable `search` output: the "Found N issue candidates"
25
+ * block with per-candidate icon, personalization and stalled tags, and the
26
+ * optional repoScore line. The trailing rate-limit warning is NOT included
27
+ * here; it goes to stderr in the caller.
28
+ */
29
+ export function renderSearch(results) {
30
+ const lines = [];
31
+ lines.push(`\nFound ${results.candidates.length} issue candidates:\n`);
32
+ for (const c of results.candidates) {
33
+ const icon = recommendationIcon(c.recommendation);
34
+ const stalledTag = c.linkedPR?.isStalled
35
+ ? " (stalled PR, revive opportunity)"
36
+ : "";
37
+ // Personalization tag (#1244). A candidate is either boosted (matched a
38
+ // preference) or a diversity slot (matched none and filled a reserved
39
+ // slot); never both.
40
+ let personalizationTag = "";
41
+ if (c.boostReasons && c.boostReasons.length > 0) {
42
+ // Net score can be negative when avoidRepos applied (#168).
43
+ const verb = (c.boostScore ?? 0) >= 0 ? "boosted" : "deprioritized";
44
+ personalizationTag = ` [${verb}: ${c.boostReasons.join("; ")}]`;
45
+ }
46
+ else if (c.diversitySlot) {
47
+ personalizationTag = " [diversity slot]";
48
+ }
49
+ lines.push(` ${icon} ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100]${personalizationTag}${stalledTag}`);
50
+ lines.push(` ${c.issue.title}`);
51
+ lines.push(` ${c.issue.url}`);
52
+ if (c.repoScore) {
53
+ lines.push(` Repo: ${c.repoScore.score}/10, ${c.repoScore.mergedPRCount} merged PRs`);
54
+ }
55
+ lines.push("");
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+ /**
60
+ * Render the human-readable `features` output: the optional message, the
61
+ * "Feature opportunities" header, the anchor repos line, and the Quick wins /
62
+ * Bigger bets sections. Returns "" when there is nothing to print beyond an
63
+ * absent message (caller guards against logging a blank line).
64
+ */
65
+ export function renderFeatures(result, options) {
66
+ const lines = [];
67
+ const total = result.quickWins.length + result.biggerBets.length;
68
+ if (result.message) {
69
+ lines.push(`\n${result.message}\n`);
70
+ }
71
+ if (total === 0)
72
+ return lines.join("\n");
73
+ const headerScope = options.broad
74
+ ? "across the ecosystem"
75
+ : "in your anchor repos";
76
+ lines.push(`\n🎯 Feature opportunities ${headerScope} (${result.quickWins.length} quick wins + ${result.biggerBets.length} bigger bets)\n`);
77
+ if (!options.broad) {
78
+ lines.push(`Anchor repos: ${result.anchorRepos.join(", ")}\n`);
79
+ }
80
+ if (result.quickWins.length) {
81
+ lines.push("── Quick wins ─────────────────────────────────────────");
82
+ for (const c of result.quickWins) {
83
+ const stalledTag = c.linkedPR?.isStalled
84
+ ? " (stalled PR, revive opportunity)"
85
+ : "";
86
+ lines.push(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
87
+ lines.push(` ${c.issue.url}`);
88
+ }
89
+ lines.push("");
90
+ }
91
+ if (result.biggerBets.length) {
92
+ lines.push("── Bigger bets ────────────────────────────────────────");
93
+ for (const c of result.biggerBets) {
94
+ const stalledTag = c.linkedPR?.isStalled
95
+ ? " (stalled PR, revive opportunity)"
96
+ : "";
97
+ lines.push(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
98
+ lines.push(` ${c.issue.url}`);
99
+ }
100
+ lines.push("");
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+ /** The empty-state message printed by `results` when nothing is saved. */
105
+ export const RESULTS_EMPTY_MESSAGE = "\nNo saved results. Run `oss-scout search` to find issues.\n";
106
+ /**
107
+ * Render the human-readable `results` table: the "Saved results" header and a
108
+ * Score / Repo / Issue / Recommendation / Title row per saved candidate.
109
+ * Callers handle the empty state (RESULTS_EMPTY_MESSAGE) separately.
110
+ */
111
+ export function renderResults(results) {
112
+ const lines = [];
113
+ lines.push(`\nSaved results (${results.length}):\n`);
114
+ lines.push(" Score Repo Issue Recommendation Title");
115
+ lines.push(" ───── ──────────────────────────────── ────── ────────────── ─────");
116
+ for (const r of results) {
117
+ const score = String(r.viabilityScore).padStart(3);
118
+ const repo = r.repo.padEnd(32).slice(0, 32);
119
+ const issue = `#${r.number}`.padEnd(6);
120
+ const rec = r.recommendation.padEnd(14);
121
+ const title = r.title.length > 50 ? r.title.slice(0, 47) + "..." : r.title;
122
+ lines.push(` ${score} ${repo} ${issue} ${rec} ${title}`);
123
+ }
124
+ lines.push("");
125
+ return lines.join("\n");
126
+ }
127
+ /** The empty-state message printed by `vet-list` when there is nothing to vet. */
128
+ export const VET_LIST_EMPTY_MESSAGE = "\nNo saved results to vet. Run `oss-scout search` first.\n";
129
+ /** Icon for a vet-list entry's availability status. */
130
+ function vetListStatusIcon(status) {
131
+ return status === "still_available"
132
+ ? "✅"
133
+ : status === "claimed"
134
+ ? "🔒"
135
+ : status === "has_pr"
136
+ ? "🔀"
137
+ : status === "closed"
138
+ ? "🚫"
139
+ : "❌";
140
+ }
141
+ /**
142
+ * Render the human-readable `vet-list` output: the "Vet-list results (N)"
143
+ * block with a per-row status icon, the "Changes since last check"
144
+ * transitions block, the summary line, and the optional pruned-count line.
145
+ * Callers handle the empty state (VET_LIST_EMPTY_MESSAGE) separately.
146
+ */
147
+ export function renderVetList(result) {
148
+ const lines = [];
149
+ lines.push(`\nVet-list results (${result.summary.total}):\n`);
150
+ for (const r of result.results) {
151
+ const icon = vetListStatusIcon(r.status);
152
+ const score = r.ok ? ` [${r.viabilityScore}/100]` : "";
153
+ lines.push(` ${icon} ${r.repo}#${r.number} — ${r.status}${score}`);
154
+ lines.push(` ${r.title}`);
155
+ }
156
+ if (result.transitions.length > 0) {
157
+ lines.push(`\n🔔 Changes since last check (${result.transitions.length}):`);
158
+ for (const t of result.transitions) {
159
+ lines.push(` ${t.repo}#${t.number}: ${t.from} → ${t.to}`);
160
+ }
161
+ }
162
+ lines.push(`\nSummary: ${result.summary.stillAvailable} available, ${result.summary.claimed} claimed, ${result.summary.hasPR} has PR, ${result.summary.closed} closed, ${result.summary.errors} errors`);
163
+ if (result.prunedCount != null) {
164
+ lines.push(`Pruned ${result.prunedCount} unavailable issues from saved results.`);
165
+ }
166
+ lines.push("");
167
+ return lines.join("\n");
168
+ }
169
+ /**
170
+ * Render the human-readable single-issue `vet` output: the recommendation
171
+ * header, the reasons to approve / skip, and the project-health block. The
172
+ * checkFailed branch (#158) is preserved exactly.
173
+ */
174
+ export function renderVet(result) {
175
+ const lines = [];
176
+ const icon = recommendationIcon(result.recommendation);
177
+ lines.push(`\n${icon} ${result.issue.repo}#${result.issue.number}: ${result.recommendation.toUpperCase()}`);
178
+ lines.push(` ${result.issue.title}`);
179
+ lines.push(` ${result.issue.url}\n`);
180
+ if (result.reasonsToApprove.length > 0) {
181
+ lines.push("Reasons to approve:");
182
+ for (const r of result.reasonsToApprove)
183
+ lines.push(` + ${r}`);
184
+ }
185
+ if (result.reasonsToSkip.length > 0) {
186
+ lines.push("Reasons to skip:");
187
+ for (const r of result.reasonsToSkip)
188
+ lines.push(` - ${r}`);
189
+ }
190
+ if (result.projectHealth.checkFailed) {
191
+ lines.push(`\nProject health: unknown (check failed: ${result.projectHealth.failureReason})`);
192
+ }
193
+ else {
194
+ lines.push(`\nProject health: ${result.projectHealth.isActive ? "Active" : "Inactive"}`);
195
+ lines.push(` Last commit: ${result.projectHealth.daysSinceLastCommit} days ago`);
196
+ lines.push(` CI status: ${result.projectHealth.ciStatus}`);
197
+ }
198
+ return lines.join("\n");
199
+ }
package/dist/scout.d.ts CHANGED
@@ -9,6 +9,14 @@ import { type FeatureSearchResult } from "./core/feature-discovery.js";
9
9
  import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue, Horizon } from "./core/schemas.js";
10
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
+ import type { GistOctokitLike } from "./core/gist-state-store.js";
13
+ import type { Octokit } from "@octokit/rest";
14
+ /**
15
+ * Wrap a real Octokit instance as GistOctokitLike without unsafe double casts.
16
+ * Exported (not via the package index) so the response-narrowing logic — the
17
+ * "no id" guards and the files/list mapping — is unit-testable (#162).
18
+ */
19
+ export declare function toGistOctokit(octokit: Octokit): GistOctokitLike;
12
20
  /**
13
21
  * Create an OssScout instance.
14
22
  *
@@ -64,14 +72,13 @@ export declare class OssScout implements ScoutStateReader, ScoutStateWriter {
64
72
  * calculateScore's +1 active-maintainers weight was inert; `isActive`
65
73
  * (recent commit activity) is a real, already-computed proxy.
66
74
  *
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
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here, and
76
+ * `isResponsive` no longer carries a score weight at all (#167): real
77
+ * responsiveness needs an actual response-time measurement (extra API calls)
78
+ * that is out of scope, and `hasActiveMaintainers` already covers the
79
+ * activity proxy. `hasHostileComments` stays a host-settable capability (it
80
+ * needs comment sentiment, out of scope). A failed health check is skipped so
81
+ * its neutral-default fields don't pollute the score.
75
82
  */
76
83
  private updateRepoSignalsFromHealth;
77
84
  /**
@@ -227,9 +234,16 @@ export declare class OssScout implements ScoutStateReader, ScoutStateWriter {
227
234
  getState(): Readonly<ScoutState>;
228
235
  private updateRepoScoreFromPRs;
229
236
  /**
230
- * Calculate repo score (1-10) from observed data.
237
+ * Calculate repo score from observed data.
231
238
  * base 5, +1 per merged PR (max +3), -1 per closed-without-merge (max -3),
232
- * +1 responsive, +1 active maintainers, -2 hostile comments, clamped 1-10
239
+ * +1 active maintainers, -2 hostile comments, clamped to [1, 10].
240
+ *
241
+ * `isResponsive` is intentionally NOT scored (#167): nothing in oss-scout
242
+ * ever computes it, so awarding +1 for it was dead weight that the
243
+ * now-computed `hasActiveMaintainers` signal already covers as the activity
244
+ * proxy. The field is retained on RepoSignals for backward compatibility but
245
+ * no longer affects the score. With the current signals the reachable range
246
+ * is [1, 9]; the upper clamp stays as defensive hygiene.
233
247
  */
234
248
  private calculateScore;
235
249
  }
package/dist/scout.js CHANGED
@@ -29,8 +29,12 @@ function offlineModeMessage(reason) {
29
29
  return `Gist sync unavailable — running in offline mode. ${tail}`;
30
30
  }
31
31
  }
32
- /** Wrap a real Octokit instance as GistOctokitLike without unsafe double casts. */
33
- function toGistOctokit(octokit) {
32
+ /**
33
+ * Wrap a real Octokit instance as GistOctokitLike without unsafe double casts.
34
+ * Exported (not via the package index) so the response-narrowing logic — the
35
+ * "no id" guards and the files/list mapping — is unit-testable (#162).
36
+ */
37
+ export function toGistOctokit(octokit) {
34
38
  return {
35
39
  gists: {
36
40
  async get(params) {
@@ -175,6 +179,11 @@ export class OssScout {
175
179
  const preferLanguages = options?.preferLanguages ??
176
180
  (prefLangs.length > 0 ? prefLangs : undefined);
177
181
  const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : undefined);
182
+ const prefAvoid = prefs.avoidRepos ?? [];
183
+ const prefBoostTypes = prefs.boostIssueTypes ?? [];
184
+ const avoidRepos = options?.avoidRepos ?? (prefAvoid.length > 0 ? prefAvoid : undefined);
185
+ const boostIssueTypes = options?.boostIssueTypes ??
186
+ (prefBoostTypes.length > 0 ? prefBoostTypes : undefined);
178
187
  const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
179
188
  const { candidates, strategiesUsed } = await discovery.searchIssues({
180
189
  maxResults: options?.maxResults,
@@ -182,6 +191,8 @@ export class OssScout {
182
191
  skippedUrls,
183
192
  preferLanguages,
184
193
  preferRepos,
194
+ avoidRepos,
195
+ boostIssueTypes,
185
196
  diversityRatio,
186
197
  interPhaseDelayMs: options?.interPhaseDelayMs,
187
198
  broadPhaseDelayMs: options?.broadPhaseDelayMs,
@@ -207,14 +218,13 @@ export class OssScout {
207
218
  * calculateScore's +1 active-maintainers weight was inert; `isActive`
208
219
  * (recent commit activity) is a real, already-computed proxy.
209
220
  *
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.
221
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here, and
222
+ * `isResponsive` no longer carries a score weight at all (#167): real
223
+ * responsiveness needs an actual response-time measurement (extra API calls)
224
+ * that is out of scope, and `hasActiveMaintainers` already covers the
225
+ * activity proxy. `hasHostileComments` stays a host-settable capability (it
226
+ * needs comment sentiment, out of scope). A failed health check is skipped so
227
+ * its neutral-default fields don't pollute the score.
218
228
  */
219
229
  updateRepoSignalsFromHealth(health) {
220
230
  if (health.checkFailed)
@@ -821,16 +831,21 @@ export class OssScout {
821
831
  });
822
832
  }
823
833
  /**
824
- * Calculate repo score (1-10) from observed data.
834
+ * Calculate repo score from observed data.
825
835
  * base 5, +1 per merged PR (max +3), -1 per closed-without-merge (max -3),
826
- * +1 responsive, +1 active maintainers, -2 hostile comments, clamped 1-10
836
+ * +1 active maintainers, -2 hostile comments, clamped to [1, 10].
837
+ *
838
+ * `isResponsive` is intentionally NOT scored (#167): nothing in oss-scout
839
+ * ever computes it, so awarding +1 for it was dead weight that the
840
+ * now-computed `hasActiveMaintainers` signal already covers as the activity
841
+ * proxy. The field is retained on RepoSignals for backward compatibility but
842
+ * no longer affects the score. With the current signals the reachable range
843
+ * is [1, 9]; the upper clamp stays as defensive hygiene.
827
844
  */
828
845
  calculateScore(repoScore) {
829
846
  let score = 5;
830
847
  score += Math.min(repoScore.mergedPRCount, 3);
831
848
  score -= Math.min(repoScore.closedWithoutMergeCount, 3);
832
- if (repoScore.signals.isResponsive)
833
- score += 1;
834
849
  if (repoScore.signals.hasActiveMaintainers)
835
850
  score += 1;
836
851
  if (repoScore.signals.hasHostileComments)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "1.0.0",
3
+ "version": "1.2.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": {