@oss-scout/core 0.11.0 → 1.1.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 +89 -66
- package/dist/cli.js +302 -436
- package/dist/commands/command-scout.d.ts +21 -0
- package/dist/commands/command-scout.js +21 -0
- package/dist/commands/config.js +10 -128
- package/dist/commands/features.js +15 -28
- package/dist/commands/results.d.ts +13 -2
- package/dist/commands/results.js +29 -2
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +65 -70
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +35 -6
- package/dist/commands/skip.d.ts +4 -0
- package/dist/commands/skip.js +45 -55
- package/dist/commands/sync.d.ts +10 -0
- package/dist/commands/sync.js +10 -0
- package/dist/commands/vet-list.js +3 -19
- package/dist/commands/vet.js +18 -25
- package/dist/commands/with-scout.d.ts +32 -0
- package/dist/commands/with-scout.js +41 -0
- package/dist/core/anti-llm-policy.js +5 -33
- package/dist/core/bootstrap.d.ts +2 -2
- package/dist/core/bootstrap.js +5 -9
- package/dist/core/errors.d.ts +10 -0
- package/dist/core/errors.js +20 -5
- package/dist/core/feature-discovery.d.ts +13 -1
- package/dist/core/feature-discovery.js +104 -81
- package/dist/core/gist-state-store.d.ts +13 -12
- package/dist/core/gist-state-store.js +128 -53
- package/dist/core/http-cache.d.ts +32 -2
- package/dist/core/http-cache.js +74 -19
- package/dist/core/issue-discovery.d.ts +12 -1
- package/dist/core/issue-discovery.js +94 -67
- package/dist/core/issue-eligibility.d.ts +11 -4
- package/dist/core/issue-eligibility.js +124 -69
- package/dist/core/issue-graphql.d.ts +58 -0
- package/dist/core/issue-graphql.js +108 -0
- package/dist/core/issue-vetting.d.ts +115 -9
- package/dist/core/issue-vetting.js +246 -109
- package/dist/core/local-state.d.ts +6 -2
- package/dist/core/local-state.js +23 -5
- package/dist/core/logger.d.ts +12 -4
- package/dist/core/logger.js +33 -7
- package/dist/core/personalization.d.ts +30 -10
- package/dist/core/personalization.js +64 -24
- package/dist/core/preference-fields.d.ts +47 -0
- package/dist/core/preference-fields.js +180 -0
- package/dist/core/probe-repo-file.d.ts +47 -0
- package/dist/core/probe-repo-file.js +57 -0
- package/dist/core/repo-health.js +40 -32
- package/dist/core/roadmap.js +26 -22
- package/dist/core/schemas.d.ts +148 -26
- package/dist/core/schemas.js +83 -17
- package/dist/core/search-budget.d.ts +9 -0
- package/dist/core/search-budget.js +36 -3
- package/dist/core/search-phases.d.ts +4 -21
- package/dist/core/search-phases.js +37 -89
- package/dist/core/types.d.ts +151 -38
- package/dist/core/utils.js +60 -26
- package/dist/formatters/human.d.ts +60 -0
- package/dist/formatters/human.js +199 -0
- package/dist/formatters/markdown.d.ts +10 -0
- package/dist/formatters/markdown.js +31 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +8 -0
- package/dist/scout.d.ts +75 -12
- package/dist/scout.js +265 -26
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,8 @@ import { Command } from "commander";
|
|
|
6
6
|
import { enableDebug } from "./core/logger.js";
|
|
7
7
|
import { getCLIVersion } from "./core/utils.js";
|
|
8
8
|
import { formatJsonSuccess, formatJsonError } from "./formatters/json.js";
|
|
9
|
-
import {
|
|
9
|
+
import { renderSearch, renderFeatures, renderResults, renderVetList, renderVet, RESULTS_EMPTY_MESSAGE, VET_LIST_EMPTY_MESSAGE, } from "./formatters/human.js";
|
|
10
|
+
import { ValidationError, errorMessage, resolveErrorCode, } from "./core/errors.js";
|
|
10
11
|
import { hasLocalState, loadLocalState, saveLocalState, } from "./core/local-state.js";
|
|
11
12
|
import { CONCRETE_STRATEGIES, SearchStrategySchema } from "./core/schemas.js";
|
|
12
13
|
function handleCommandError(err, options) {
|
|
@@ -18,6 +19,18 @@ function handleCommandError(err, options) {
|
|
|
18
19
|
}
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Run a command body, routing any thrown error through handleCommandError so
|
|
24
|
+
* every command shares one try/catch epilogue instead of repeating it (#154).
|
|
25
|
+
*/
|
|
26
|
+
async function runAction(options, body) {
|
|
27
|
+
try {
|
|
28
|
+
await body();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
handleCommandError(err, options);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
21
34
|
const program = new Command();
|
|
22
35
|
program
|
|
23
36
|
.name("oss-scout")
|
|
@@ -34,157 +47,147 @@ program
|
|
|
34
47
|
.command("setup")
|
|
35
48
|
.description("Interactive first-run configuration")
|
|
36
49
|
.option("--json", "Output as JSON")
|
|
37
|
-
.action(async (options) => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
catch (err) {
|
|
49
|
-
handleCommandError(err, options);
|
|
50
|
+
.action(async (options) => runAction(options, async () => {
|
|
51
|
+
const { runSetup } = await import("./commands/setup.js");
|
|
52
|
+
const prefs = await runSetup();
|
|
53
|
+
const state = loadLocalState();
|
|
54
|
+
state.preferences = prefs;
|
|
55
|
+
state.preferencesUpdatedAt = new Date().toISOString(); // #117 merge recency
|
|
56
|
+
saveLocalState(state);
|
|
57
|
+
if (options.json) {
|
|
58
|
+
console.log(formatJsonSuccess(prefs));
|
|
50
59
|
}
|
|
51
|
-
});
|
|
60
|
+
}));
|
|
52
61
|
program
|
|
53
62
|
.command("bootstrap")
|
|
54
63
|
.description("Import starred repos and PR history from GitHub")
|
|
55
64
|
.option("--json", "Output as JSON")
|
|
56
|
-
.action(async (options) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
.action(async (options) => runAction(options, async () => {
|
|
66
|
+
const { bootstrapScout } = await import("./core/bootstrap.js");
|
|
67
|
+
const { createScout } = await import("./scout.js");
|
|
68
|
+
const { requireGitHubToken } = await import("./core/utils.js");
|
|
69
|
+
const token = requireGitHubToken();
|
|
70
|
+
const state = loadLocalState();
|
|
71
|
+
const scout = await createScout({
|
|
72
|
+
githubToken: token,
|
|
73
|
+
persistence: "provided",
|
|
74
|
+
initialState: state,
|
|
75
|
+
});
|
|
76
|
+
const result = await bootstrapScout(scout, token);
|
|
77
|
+
saveLocalState(scout.getState());
|
|
78
|
+
if (options.json) {
|
|
79
|
+
console.log(formatJsonSuccess(result));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
if (result.skippedDueToRateLimit) {
|
|
83
|
+
console.log("Skipped: GitHub API rate limit too low. Try again later.");
|
|
72
84
|
}
|
|
73
85
|
else {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
console.log(`Imported ${result.mergedPRCount} merged PRs, ${result.closedPRCount} closed PRs, ${result.starredRepoCount} starred repos`);
|
|
79
|
-
console.log(`Scored ${result.reposScoredCount} repositories`);
|
|
80
|
-
}
|
|
86
|
+
console.log(`Imported ${result.mergedPRCount} merged PRs, ${result.closedPRCount} closed PRs, ${result.starredRepoCount} starred repos`);
|
|
87
|
+
console.log(`Scored ${result.reposScoredCount} repositories`);
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
}));
|
|
91
|
+
program
|
|
92
|
+
.command("sync")
|
|
93
|
+
.description("Reconcile tracked open PRs (mark merged/closed) without a full bootstrap")
|
|
94
|
+
.option("--json", "Output as JSON")
|
|
95
|
+
.action(async (options) => runAction(options, async () => {
|
|
96
|
+
const { runSync } = await import("./commands/sync.js");
|
|
97
|
+
const result = await runSync();
|
|
98
|
+
if (options.json) {
|
|
99
|
+
console.log(formatJsonSuccess(result));
|
|
85
100
|
}
|
|
86
|
-
|
|
101
|
+
else {
|
|
102
|
+
console.log(`Synced ${result.checked} open PRs: ${result.merged} merged, ${result.closed} closed, ${result.stillOpen} still open${result.errors > 0 ? `, ${result.errors} unchecked` : ""}.`);
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
87
105
|
program
|
|
88
106
|
.command("search [count]")
|
|
89
107
|
.description("Search for contributable issues using multi-strategy discovery")
|
|
90
108
|
.option("--json", "Output as JSON")
|
|
91
|
-
.option("--strategy <strategies>", `Search strategies (${CONCRETE_STRATEGIES.join(",")},all)
|
|
109
|
+
.option("--strategy <strategies>", `Search strategies (${CONCRETE_STRATEGIES.join(",")},all). Defaults to the defaultStrategy preference, or all.`)
|
|
92
110
|
.option("--prefer-languages <list>", "Comma-separated languages to soft-boost in ranking (#1244). Candidates whose repo language matches sort above equally-recommended non-matches. Does not filter results.")
|
|
93
111
|
.option("--prefer-repos <list>", "Comma-separated `owner/repo` slugs to soft-boost in ranking (#1244). Stronger weight than language match. Does not filter results.")
|
|
112
|
+
.option("--avoid-repos <list>", "Comma-separated `owner/repo` slugs to soft-penalize in ranking (#168). Milder than excludeRepos: pushes them down but does not filter them out.")
|
|
113
|
+
.option("--boost-issue-types <list>", "Comma-separated issue label types to soft-boost in ranking (#168), case-insensitive (e.g. `bug,good first issue`). Does not filter results.")
|
|
94
114
|
.option("--diversity-ratio <n>", "Fraction of result slots (0-1) reserved for candidates that matched NEITHER preference list (#1244). Counterweights echo-chamber bias as boosts accumulate. Default 0 (disabled).")
|
|
95
|
-
.action(async (count, options) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
.action(async (count, options) => runAction(options, async () => {
|
|
116
|
+
if (!hasLocalState() && !options.json) {
|
|
117
|
+
// Human hint only: stdout must stay pure JSON under --json (#131)
|
|
118
|
+
console.log("💡 Run `oss-scout setup` to configure your preferences for personalized search results.\n");
|
|
119
|
+
}
|
|
120
|
+
const { runSearch } = await import("./commands/search.js");
|
|
121
|
+
const maxResults = count ? parseInt(count, 10) : 10;
|
|
122
|
+
if (isNaN(maxResults) || maxResults < 1) {
|
|
123
|
+
throw new ValidationError("count must be a positive integer");
|
|
124
|
+
}
|
|
125
|
+
const state = loadLocalState();
|
|
126
|
+
if (state.mergedPRs.length === 0 &&
|
|
127
|
+
state.starredRepos.length === 0 &&
|
|
128
|
+
state.preferences.githubUsername &&
|
|
129
|
+
!options.json) {
|
|
130
|
+
console.log("Run `oss-scout bootstrap` to import your starred repos and PR history for better results.\n");
|
|
131
|
+
}
|
|
132
|
+
// Parse --strategy. Absent means undefined so the stored
|
|
133
|
+
// defaultStrategy preference applies (discovery falls back to "all").
|
|
134
|
+
let strategies;
|
|
135
|
+
if (options.strategy !== undefined) {
|
|
136
|
+
const strategyTokens = options.strategy
|
|
114
137
|
.split(",")
|
|
115
138
|
.map((s) => s.trim())
|
|
116
139
|
.filter(Boolean);
|
|
117
|
-
|
|
140
|
+
strategies = [];
|
|
118
141
|
for (const token of strategyTokens) {
|
|
119
142
|
const parsed = SearchStrategySchema.safeParse(token);
|
|
120
143
|
if (!parsed.success) {
|
|
121
144
|
const valid = [...CONCRETE_STRATEGIES, "all"].join(", ");
|
|
122
|
-
|
|
123
|
-
token +
|
|
124
|
-
'". Valid strategies: ' +
|
|
125
|
-
valid);
|
|
126
|
-
process.exit(1);
|
|
145
|
+
throw new ValidationError(`unknown strategy "${token}". Valid strategies: ${valid}`);
|
|
127
146
|
}
|
|
128
147
|
strategies.push(parsed.data);
|
|
129
148
|
}
|
|
130
|
-
const splitCsv = (raw) => {
|
|
131
|
-
if (!raw)
|
|
132
|
-
return undefined;
|
|
133
|
-
const parts = raw
|
|
134
|
-
.split(",")
|
|
135
|
-
.map((s) => s.trim())
|
|
136
|
-
.filter(Boolean);
|
|
137
|
-
return parts.length > 0 ? parts : undefined;
|
|
138
|
-
};
|
|
139
|
-
let diversityRatio;
|
|
140
|
-
if (options.diversityRatio !== undefined) {
|
|
141
|
-
const parsed = Number(options.diversityRatio);
|
|
142
|
-
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
143
|
-
console.error(`Error: --diversity-ratio must be a number in [0, 1] (got "${options.diversityRatio}")`);
|
|
144
|
-
process.exit(1);
|
|
145
|
-
}
|
|
146
|
-
diversityRatio = parsed;
|
|
147
|
-
}
|
|
148
|
-
const results = await runSearch({
|
|
149
|
-
maxResults,
|
|
150
|
-
state,
|
|
151
|
-
strategies,
|
|
152
|
-
preferLanguages: splitCsv(options.preferLanguages),
|
|
153
|
-
preferRepos: splitCsv(options.preferRepos),
|
|
154
|
-
diversityRatio,
|
|
155
|
-
});
|
|
156
|
-
if (options.json) {
|
|
157
|
-
console.log(formatJsonSuccess(results));
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
// Human-readable output
|
|
161
|
-
console.log(`\nFound ${results.candidates.length} issue candidates:\n`);
|
|
162
|
-
for (const c of results.candidates) {
|
|
163
|
-
const icon = c.recommendation === "approve"
|
|
164
|
-
? "✅"
|
|
165
|
-
: c.recommendation === "skip"
|
|
166
|
-
? "❌"
|
|
167
|
-
: "⚠️";
|
|
168
|
-
const stalledTag = c.linkedPR?.isStalled
|
|
169
|
-
? " (stalled PR, revive opportunity)"
|
|
170
|
-
: "";
|
|
171
|
-
console.log(` ${icon} ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100]${stalledTag}`);
|
|
172
|
-
console.log(` ${c.issue.title}`);
|
|
173
|
-
console.log(` ${c.issue.url}`);
|
|
174
|
-
if (c.repoScore) {
|
|
175
|
-
console.log(` Repo: ${c.repoScore.score}/10, ${c.repoScore.mergedPRCount} merged PRs`);
|
|
176
|
-
}
|
|
177
|
-
console.log();
|
|
178
|
-
}
|
|
179
|
-
if (results.rateLimitWarning) {
|
|
180
|
-
console.error(`\n⚠️ ${results.rateLimitWarning}`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
149
|
}
|
|
184
|
-
|
|
185
|
-
|
|
150
|
+
const splitCsv = (raw) => {
|
|
151
|
+
if (!raw)
|
|
152
|
+
return undefined;
|
|
153
|
+
const parts = raw
|
|
154
|
+
.split(",")
|
|
155
|
+
.map((s) => s.trim())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
return parts.length > 0 ? parts : undefined;
|
|
158
|
+
};
|
|
159
|
+
let diversityRatio;
|
|
160
|
+
if (options.diversityRatio !== undefined) {
|
|
161
|
+
const parsed = Number(options.diversityRatio);
|
|
162
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
163
|
+
throw new ValidationError(`--diversity-ratio must be a number in [0, 1] (got "${options.diversityRatio}")`);
|
|
164
|
+
}
|
|
165
|
+
diversityRatio = parsed;
|
|
166
|
+
}
|
|
167
|
+
const results = await runSearch({
|
|
168
|
+
maxResults,
|
|
169
|
+
state,
|
|
170
|
+
strategies,
|
|
171
|
+
preferLanguages: splitCsv(options.preferLanguages),
|
|
172
|
+
preferRepos: splitCsv(options.preferRepos),
|
|
173
|
+
avoidRepos: splitCsv(options.avoidRepos),
|
|
174
|
+
boostIssueTypes: splitCsv(options.boostIssueTypes),
|
|
175
|
+
diversityRatio,
|
|
176
|
+
});
|
|
177
|
+
if (options.json) {
|
|
178
|
+
console.log(formatJsonSuccess(results));
|
|
186
179
|
}
|
|
187
|
-
|
|
180
|
+
else {
|
|
181
|
+
// Human-readable output
|
|
182
|
+
console.log(renderSearch(results));
|
|
183
|
+
// Rate-limit warning stays on stderr (NOT folded into the stdout
|
|
184
|
+
// render), so --json stdout purity and the stdout/stderr split are
|
|
185
|
+
// both preserved.
|
|
186
|
+
if (results.rateLimitWarning) {
|
|
187
|
+
console.error(`\n⚠️ ${results.rateLimitWarning}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}));
|
|
188
191
|
program
|
|
189
192
|
.command("features [count]")
|
|
190
193
|
.description("Surface feature-scoped opportunities in repos where you have 3+ merged PRs")
|
|
@@ -192,85 +195,47 @@ program
|
|
|
192
195
|
.option("--anchor-threshold <n>", "Override featuresAnchorThreshold (1-50)")
|
|
193
196
|
.option("--split-ratio <r>", "Override featuresSplitRatio (0-1, e.g. 0.6)")
|
|
194
197
|
.option("--broad", "Bypass anchor repos; search feature issues across the ecosystem (first-touch mode)")
|
|
195
|
-
.action(async (count, options) => {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
broad: options.broad,
|
|
228
|
-
});
|
|
229
|
-
if (options.json) {
|
|
230
|
-
console.log(formatJsonSuccess(result));
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
const total = result.quickWins.length + result.biggerBets.length;
|
|
234
|
-
if (result.message) {
|
|
235
|
-
console.log(`\n${result.message}\n`);
|
|
236
|
-
}
|
|
237
|
-
if (total === 0)
|
|
238
|
-
return;
|
|
239
|
-
const headerScope = options.broad
|
|
240
|
-
? "across the ecosystem"
|
|
241
|
-
: "in your anchor repos";
|
|
242
|
-
console.log(`\n🎯 Feature opportunities ${headerScope} (${result.quickWins.length} quick wins + ${result.biggerBets.length} bigger bets)\n`);
|
|
243
|
-
if (!options.broad) {
|
|
244
|
-
console.log(`Anchor repos: ${result.anchorRepos.join(", ")}\n`);
|
|
245
|
-
}
|
|
246
|
-
if (result.quickWins.length) {
|
|
247
|
-
console.log("── Quick wins ─────────────────────────────────────────");
|
|
248
|
-
for (const c of result.quickWins) {
|
|
249
|
-
const stalledTag = c.linkedPR?.isStalled
|
|
250
|
-
? " (stalled PR, revive opportunity)"
|
|
251
|
-
: "";
|
|
252
|
-
console.log(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
|
|
253
|
-
console.log(` ${c.issue.url}`);
|
|
254
|
-
}
|
|
255
|
-
console.log("");
|
|
256
|
-
}
|
|
257
|
-
if (result.biggerBets.length) {
|
|
258
|
-
console.log("── Bigger bets ────────────────────────────────────────");
|
|
259
|
-
for (const c of result.biggerBets) {
|
|
260
|
-
const stalledTag = c.linkedPR?.isStalled
|
|
261
|
-
? " (stalled PR, revive opportunity)"
|
|
262
|
-
: "";
|
|
263
|
-
console.log(` ${c.issue.repo}#${c.issue.number} [${c.viabilityScore}/100] ${c.issue.title}${stalledTag}`);
|
|
264
|
-
console.log(` ${c.issue.url}`);
|
|
265
|
-
}
|
|
266
|
-
console.log("");
|
|
267
|
-
}
|
|
268
|
-
}
|
|
198
|
+
.action(async (count, options) => runAction(options, async () => {
|
|
199
|
+
const { runFeatures } = await import("./commands/features.js");
|
|
200
|
+
const maxResults = count ? parseInt(count, 10) : 10;
|
|
201
|
+
if (isNaN(maxResults) || maxResults < 1 || maxResults > 50) {
|
|
202
|
+
throw new ValidationError("count must be an integer between 1 and 50");
|
|
203
|
+
}
|
|
204
|
+
let anchorThreshold;
|
|
205
|
+
if (options.anchorThreshold !== undefined) {
|
|
206
|
+
const parsed = parseInt(options.anchorThreshold, 10);
|
|
207
|
+
if (isNaN(parsed) || parsed < 1 || parsed > 50) {
|
|
208
|
+
throw new ValidationError("--anchor-threshold must be an integer between 1 and 50");
|
|
209
|
+
}
|
|
210
|
+
anchorThreshold = parsed;
|
|
211
|
+
}
|
|
212
|
+
let splitRatio;
|
|
213
|
+
if (options.splitRatio !== undefined) {
|
|
214
|
+
const parsed = Number.parseFloat(options.splitRatio);
|
|
215
|
+
if (isNaN(parsed) || parsed < 0 || parsed > 1) {
|
|
216
|
+
throw new ValidationError("--split-ratio must be a number between 0 and 1");
|
|
217
|
+
}
|
|
218
|
+
splitRatio = parsed;
|
|
219
|
+
}
|
|
220
|
+
const state = loadLocalState();
|
|
221
|
+
const result = await runFeatures({
|
|
222
|
+
maxResults,
|
|
223
|
+
state,
|
|
224
|
+
anchorThreshold,
|
|
225
|
+
splitRatio,
|
|
226
|
+
broad: options.broad,
|
|
227
|
+
});
|
|
228
|
+
if (options.json) {
|
|
229
|
+
console.log(formatJsonSuccess(result));
|
|
269
230
|
}
|
|
270
|
-
|
|
271
|
-
|
|
231
|
+
else {
|
|
232
|
+
// renderFeatures returns "" only when there is no message AND
|
|
233
|
+
// nothing to list; guard so the caller never logs a blank line.
|
|
234
|
+
const out = renderFeatures(result, { broad: options.broad });
|
|
235
|
+
if (out)
|
|
236
|
+
console.log(out);
|
|
272
237
|
}
|
|
273
|
-
});
|
|
238
|
+
}));
|
|
274
239
|
// ── results command ────────────────────────────────────────────────
|
|
275
240
|
const resultsCmd = program
|
|
276
241
|
.command("results")
|
|
@@ -279,166 +244,112 @@ resultsCmd
|
|
|
279
244
|
.command("show", { isDefault: true })
|
|
280
245
|
.description("Display saved search results")
|
|
281
246
|
.option("--json", "Output as JSON")
|
|
282
|
-
.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
console.log("\nNo saved results. Run `oss-scout search` to find issues.\n");
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
console.log(`\nSaved results (${results.length}):\n`);
|
|
295
|
-
console.log(" Score Repo Issue Recommendation Title");
|
|
296
|
-
console.log(" ───── ──────────────────────────────── ────── ────────────── ─────");
|
|
297
|
-
for (const r of results) {
|
|
298
|
-
const score = String(r.viabilityScore).padStart(3);
|
|
299
|
-
const repo = r.repo.padEnd(32).slice(0, 32);
|
|
300
|
-
const issue = `#${r.number}`.padEnd(6);
|
|
301
|
-
const rec = r.recommendation.padEnd(14);
|
|
302
|
-
const title = r.title.length > 50 ? r.title.slice(0, 47) + "..." : r.title;
|
|
303
|
-
console.log(` ${score} ${repo} ${issue} ${rec} ${title}`);
|
|
304
|
-
}
|
|
305
|
-
console.log();
|
|
306
|
-
}
|
|
247
|
+
.option("--markdown", "Output as a markdown table (for digests)")
|
|
248
|
+
.option("--new-only", "Only results first seen during/after the last search")
|
|
249
|
+
.option("--since <date>", "Only results first seen at/after this date")
|
|
250
|
+
.action(async (options) => runAction(options, async () => {
|
|
251
|
+
const { runResults } = await import("./commands/results.js");
|
|
252
|
+
const results = await runResults(options);
|
|
253
|
+
if (options.json) {
|
|
254
|
+
console.log(formatJsonSuccess(results));
|
|
255
|
+
return;
|
|
307
256
|
}
|
|
308
|
-
|
|
309
|
-
|
|
257
|
+
if (options.markdown) {
|
|
258
|
+
const { formatResultsMarkdown } = await import("./formatters/markdown.js");
|
|
259
|
+
console.log(formatResultsMarkdown(results));
|
|
260
|
+
return;
|
|
310
261
|
}
|
|
311
|
-
|
|
262
|
+
if (results.length === 0) {
|
|
263
|
+
console.log(RESULTS_EMPTY_MESSAGE);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
console.log(renderResults(results));
|
|
267
|
+
}));
|
|
312
268
|
resultsCmd
|
|
313
269
|
.command("clear")
|
|
314
270
|
.description("Clear all saved results")
|
|
315
271
|
.option("--json", "Output as JSON")
|
|
316
|
-
.action(async (options) => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
console.log(formatJsonSuccess({ cleared: true }));
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
console.log("Saved results cleared.");
|
|
325
|
-
}
|
|
272
|
+
.action(async (options) => runAction(options, async () => {
|
|
273
|
+
const { runResultsClear } = await import("./commands/results.js");
|
|
274
|
+
await runResultsClear();
|
|
275
|
+
if (options.json) {
|
|
276
|
+
console.log(formatJsonSuccess({ cleared: true }));
|
|
326
277
|
}
|
|
327
|
-
|
|
328
|
-
|
|
278
|
+
else {
|
|
279
|
+
console.log("Saved results cleared.");
|
|
329
280
|
}
|
|
330
|
-
});
|
|
281
|
+
}));
|
|
331
282
|
// ── config command ──────────────────────────────────────────────────
|
|
332
283
|
const configCmd = program
|
|
333
284
|
.command("config")
|
|
334
285
|
.description("View and update preferences")
|
|
335
286
|
.option("--json", "Output as JSON")
|
|
336
|
-
.action(async (options) => {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
console.log(formatJsonSuccess(getConfigData()));
|
|
341
|
-
}
|
|
342
|
-
else {
|
|
343
|
-
runConfigShow();
|
|
344
|
-
}
|
|
287
|
+
.action(async (options) => runAction(options, async () => {
|
|
288
|
+
const { runConfigShow, getConfigData } = await import("./commands/config.js");
|
|
289
|
+
if (options.json) {
|
|
290
|
+
console.log(formatJsonSuccess(getConfigData()));
|
|
345
291
|
}
|
|
346
|
-
|
|
347
|
-
|
|
292
|
+
else {
|
|
293
|
+
runConfigShow();
|
|
348
294
|
}
|
|
349
|
-
});
|
|
295
|
+
}));
|
|
350
296
|
configCmd
|
|
351
297
|
.command("set <key> <value>")
|
|
352
|
-
.description(
|
|
298
|
+
.description('Update a single preference (e.g. config set minStars 100). For dash-prefixed values like the array-remove form, escape with --: config set excludeRepos -- "-spam/repo"')
|
|
353
299
|
.option("--json", "Output as JSON")
|
|
354
|
-
.action(async (key, value, options) => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
console.log(formatJsonSuccess(updated));
|
|
360
|
-
}
|
|
361
|
-
else {
|
|
362
|
-
console.log(`✅ Updated "${key}" successfully.`);
|
|
363
|
-
}
|
|
300
|
+
.action(async (key, value, options) => runAction(options, async () => {
|
|
301
|
+
const { runConfigSet } = await import("./commands/config.js");
|
|
302
|
+
const updated = runConfigSet(key, value);
|
|
303
|
+
if (options.json) {
|
|
304
|
+
console.log(formatJsonSuccess(updated));
|
|
364
305
|
}
|
|
365
|
-
|
|
366
|
-
|
|
306
|
+
else {
|
|
307
|
+
console.log(`✅ Updated "${key}" successfully.`);
|
|
367
308
|
}
|
|
368
|
-
});
|
|
309
|
+
}));
|
|
369
310
|
configCmd
|
|
370
311
|
.command("reset")
|
|
371
312
|
.description("Reset all preferences to defaults")
|
|
372
313
|
.option("--json", "Output as JSON")
|
|
373
|
-
.action(async (options) => {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
console.log(formatJsonSuccess(defaults));
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
console.log("✅ Preferences reset to defaults.");
|
|
382
|
-
}
|
|
314
|
+
.action(async (options) => runAction(options, async () => {
|
|
315
|
+
const { runConfigReset } = await import("./commands/config.js");
|
|
316
|
+
const defaults = runConfigReset();
|
|
317
|
+
if (options.json) {
|
|
318
|
+
console.log(formatJsonSuccess(defaults));
|
|
383
319
|
}
|
|
384
|
-
|
|
385
|
-
|
|
320
|
+
else {
|
|
321
|
+
console.log("✅ Preferences reset to defaults.");
|
|
386
322
|
}
|
|
387
|
-
});
|
|
323
|
+
}));
|
|
388
324
|
program
|
|
389
325
|
.command("vet-list")
|
|
390
326
|
.description("Re-vet all saved search results and classify their current status")
|
|
391
327
|
.option("--prune", "Remove unavailable issues from saved results")
|
|
392
328
|
.option("--concurrency <n>", "Max concurrent API requests (default: 5)", parseInt)
|
|
393
329
|
.option("--json", "Output as JSON")
|
|
394
|
-
.action(async (options) => {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (options.json) {
|
|
409
|
-
console.log(formatJsonSuccess(result));
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
if (result.results.length === 0) {
|
|
413
|
-
console.log("\nNo saved results to vet. Run `oss-scout search` first.\n");
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
console.log(`\nVet-list results (${result.summary.total}):\n`);
|
|
417
|
-
for (const r of result.results) {
|
|
418
|
-
const icon = r.status === "still_available"
|
|
419
|
-
? "✅"
|
|
420
|
-
: r.status === "claimed"
|
|
421
|
-
? "🔒"
|
|
422
|
-
: r.status === "has_pr"
|
|
423
|
-
? "🔀"
|
|
424
|
-
: r.status === "closed"
|
|
425
|
-
? "🚫"
|
|
426
|
-
: "❌";
|
|
427
|
-
const score = r.viabilityScore != null ? ` [${r.viabilityScore}/100]` : "";
|
|
428
|
-
console.log(` ${icon} ${r.repo}#${r.number} — ${r.status}${score}`);
|
|
429
|
-
console.log(` ${r.title}`);
|
|
430
|
-
}
|
|
431
|
-
console.log(`\nSummary: ${result.summary.stillAvailable} available, ${result.summary.claimed} claimed, ${result.summary.hasPR} has PR, ${result.summary.closed} closed, ${result.summary.errors} errors`);
|
|
432
|
-
if (result.prunedCount != null) {
|
|
433
|
-
console.log(`Pruned ${result.prunedCount} unavailable issues from saved results.`);
|
|
434
|
-
}
|
|
435
|
-
console.log();
|
|
436
|
-
}
|
|
330
|
+
.action(async (options) => runAction(options, async () => {
|
|
331
|
+
if (options.concurrency !== undefined &&
|
|
332
|
+
(isNaN(options.concurrency) || options.concurrency < 1)) {
|
|
333
|
+
throw new ValidationError("--concurrency must be a positive integer");
|
|
334
|
+
}
|
|
335
|
+
const { runVetList } = await import("./commands/vet-list.js");
|
|
336
|
+
const state = loadLocalState();
|
|
337
|
+
const result = await runVetList({
|
|
338
|
+
state,
|
|
339
|
+
prune: options.prune,
|
|
340
|
+
concurrency: options.concurrency,
|
|
341
|
+
});
|
|
342
|
+
if (options.json) {
|
|
343
|
+
console.log(formatJsonSuccess(result));
|
|
437
344
|
}
|
|
438
|
-
|
|
439
|
-
|
|
345
|
+
else {
|
|
346
|
+
if (result.results.length === 0) {
|
|
347
|
+
console.log(VET_LIST_EMPTY_MESSAGE);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
console.log(renderVetList(result));
|
|
440
351
|
}
|
|
441
|
-
});
|
|
352
|
+
}));
|
|
442
353
|
// ── skip command ───────────────────────────────────────────────────
|
|
443
354
|
const skipCmd = program
|
|
444
355
|
.command("skip")
|
|
@@ -447,143 +358,98 @@ skipCmd
|
|
|
447
358
|
.command("add <issue-url>")
|
|
448
359
|
.description("Skip an issue by URL")
|
|
449
360
|
.option("--json", "Output as JSON")
|
|
450
|
-
.action(async (issueUrl, options) => {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
361
|
+
.action(async (issueUrl, options) => runAction(options, async () => {
|
|
362
|
+
const { runSkip } = await import("./commands/skip.js");
|
|
363
|
+
const state = loadLocalState();
|
|
364
|
+
const result = await runSkip({ issueUrl, state });
|
|
365
|
+
if (options.json) {
|
|
366
|
+
console.log(formatJsonSuccess(result));
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
if (result.alreadySkipped) {
|
|
370
|
+
console.log("Issue already in skip list.");
|
|
457
371
|
}
|
|
458
372
|
else {
|
|
459
|
-
|
|
460
|
-
console.log("Issue already in skip list.");
|
|
461
|
-
}
|
|
462
|
-
else {
|
|
463
|
-
console.log(`Skipped: ${issueUrl}`);
|
|
464
|
-
}
|
|
373
|
+
console.log(`Skipped: ${issueUrl}`);
|
|
465
374
|
}
|
|
466
375
|
}
|
|
467
|
-
|
|
468
|
-
handleCommandError(err, options);
|
|
469
|
-
}
|
|
470
|
-
});
|
|
376
|
+
}));
|
|
471
377
|
skipCmd
|
|
472
378
|
.command("list")
|
|
473
379
|
.description("Show all skipped issues")
|
|
474
380
|
.option("--json", "Output as JSON")
|
|
475
|
-
.action(async (options) => {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
console.log(formatJsonSuccess(results));
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
if (results.length === 0) {
|
|
484
|
-
console.log("\nNo skipped issues.\n");
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
console.log(`\nSkipped issues (${results.length}):\n`);
|
|
488
|
-
console.log(" Repo Issue Skipped Title");
|
|
489
|
-
console.log(" ──────────────────────────────── ────── ────────── ─────");
|
|
490
|
-
for (const s of results) {
|
|
491
|
-
const repo = (s.repo || "unknown").padEnd(32).slice(0, 32);
|
|
492
|
-
const issue = s.number ? `#${s.number}`.padEnd(6) : "—".padEnd(6);
|
|
493
|
-
const skippedDate = s.skippedAt.split("T")[0] ?? "";
|
|
494
|
-
const title = s.title.length > 50
|
|
495
|
-
? s.title.slice(0, 47) + "..."
|
|
496
|
-
: s.title || s.url;
|
|
497
|
-
console.log(` ${repo} ${issue} ${skippedDate} ${title}`);
|
|
498
|
-
}
|
|
499
|
-
console.log();
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
catch (err) {
|
|
503
|
-
handleCommandError(err, options);
|
|
381
|
+
.action(async (options) => runAction(options, async () => {
|
|
382
|
+
const { runSkipList } = await import("./commands/skip.js");
|
|
383
|
+
const results = runSkipList();
|
|
384
|
+
if (options.json) {
|
|
385
|
+
console.log(formatJsonSuccess(results));
|
|
504
386
|
}
|
|
505
|
-
|
|
387
|
+
else {
|
|
388
|
+
if (results.length === 0) {
|
|
389
|
+
console.log("\nNo skipped issues.\n");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
console.log(`\nSkipped issues (${results.length}):\n`);
|
|
393
|
+
console.log(" Repo Issue Skipped Title");
|
|
394
|
+
console.log(" ──────────────────────────────── ────── ────────── ─────");
|
|
395
|
+
for (const s of results) {
|
|
396
|
+
const repo = (s.repo || "unknown").padEnd(32).slice(0, 32);
|
|
397
|
+
const issue = s.number ? `#${s.number}`.padEnd(6) : "—".padEnd(6);
|
|
398
|
+
const skippedDate = s.skippedAt.split("T")[0] ?? "";
|
|
399
|
+
const title = s.title.length > 50
|
|
400
|
+
? s.title.slice(0, 47) + "..."
|
|
401
|
+
: s.title || s.url;
|
|
402
|
+
console.log(` ${repo} ${issue} ${skippedDate} ${title}`);
|
|
403
|
+
}
|
|
404
|
+
console.log();
|
|
405
|
+
}
|
|
406
|
+
}));
|
|
506
407
|
skipCmd
|
|
507
408
|
.command("remove <issue-url>")
|
|
508
409
|
.description("Remove an issue from the skip list (unskip)")
|
|
509
410
|
.option("--json", "Output as JSON")
|
|
510
|
-
.action(async (issueUrl, options) => {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
411
|
+
.action(async (issueUrl, options) => runAction(options, async () => {
|
|
412
|
+
const { runSkipRemove } = await import("./commands/skip.js");
|
|
413
|
+
const result = await runSkipRemove({ issueUrl });
|
|
414
|
+
if (options.json) {
|
|
415
|
+
console.log(formatJsonSuccess(result));
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
if (result.removed) {
|
|
419
|
+
console.log(`Removed from skip list: ${issueUrl}`);
|
|
516
420
|
}
|
|
517
421
|
else {
|
|
518
|
-
|
|
519
|
-
console.log(`Removed from skip list: ${issueUrl}`);
|
|
520
|
-
}
|
|
521
|
-
else {
|
|
522
|
-
console.log("Issue was not in the skip list.");
|
|
523
|
-
}
|
|
422
|
+
console.log("Issue was not in the skip list.");
|
|
524
423
|
}
|
|
525
424
|
}
|
|
526
|
-
|
|
527
|
-
handleCommandError(err, options);
|
|
528
|
-
}
|
|
529
|
-
});
|
|
425
|
+
}));
|
|
530
426
|
skipCmd
|
|
531
427
|
.command("clear")
|
|
532
428
|
.description("Clear all skipped issues")
|
|
533
429
|
.option("--json", "Output as JSON")
|
|
534
|
-
.action(async (options) => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
console.log(formatJsonSuccess({ cleared: true }));
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
console.log("Skip list cleared.");
|
|
543
|
-
}
|
|
430
|
+
.action(async (options) => runAction(options, async () => {
|
|
431
|
+
const { runSkipClear } = await import("./commands/skip.js");
|
|
432
|
+
await runSkipClear();
|
|
433
|
+
if (options.json) {
|
|
434
|
+
console.log(formatJsonSuccess({ cleared: true }));
|
|
544
435
|
}
|
|
545
|
-
|
|
546
|
-
|
|
436
|
+
else {
|
|
437
|
+
console.log("Skip list cleared.");
|
|
547
438
|
}
|
|
548
|
-
});
|
|
439
|
+
}));
|
|
549
440
|
program
|
|
550
441
|
.command("vet <issue-url>")
|
|
551
442
|
.description("Vet a specific GitHub issue for claimability and project health")
|
|
552
443
|
.option("--json", "Output as JSON")
|
|
553
|
-
.action(async (issueUrl, options) => {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
console.log(formatJsonSuccess(result));
|
|
560
|
-
}
|
|
561
|
-
else {
|
|
562
|
-
const icon = result.recommendation === "approve"
|
|
563
|
-
? "✅"
|
|
564
|
-
: result.recommendation === "skip"
|
|
565
|
-
? "❌"
|
|
566
|
-
: "⚠️";
|
|
567
|
-
console.log(`\n${icon} ${result.issue.repo}#${result.issue.number}: ${result.recommendation.toUpperCase()}`);
|
|
568
|
-
console.log(` ${result.issue.title}`);
|
|
569
|
-
console.log(` ${result.issue.url}\n`);
|
|
570
|
-
if (result.reasonsToApprove.length > 0) {
|
|
571
|
-
console.log("Reasons to approve:");
|
|
572
|
-
for (const r of result.reasonsToApprove)
|
|
573
|
-
console.log(` + ${r}`);
|
|
574
|
-
}
|
|
575
|
-
if (result.reasonsToSkip.length > 0) {
|
|
576
|
-
console.log("Reasons to skip:");
|
|
577
|
-
for (const r of result.reasonsToSkip)
|
|
578
|
-
console.log(` - ${r}`);
|
|
579
|
-
}
|
|
580
|
-
console.log(`\nProject health: ${result.projectHealth.isActive ? "Active" : "Inactive"}`);
|
|
581
|
-
console.log(` Last commit: ${result.projectHealth.daysSinceLastCommit} days ago`);
|
|
582
|
-
console.log(` CI status: ${result.projectHealth.ciStatus}`);
|
|
583
|
-
}
|
|
444
|
+
.action(async (issueUrl, options) => runAction(options, async () => {
|
|
445
|
+
const { runVet } = await import("./commands/vet.js");
|
|
446
|
+
const state = loadLocalState();
|
|
447
|
+
const result = await runVet({ issueUrl, state });
|
|
448
|
+
if (options.json) {
|
|
449
|
+
console.log(formatJsonSuccess(result));
|
|
584
450
|
}
|
|
585
|
-
|
|
586
|
-
|
|
451
|
+
else {
|
|
452
|
+
console.log(renderVet(result));
|
|
587
453
|
}
|
|
588
|
-
});
|
|
454
|
+
}));
|
|
589
455
|
program.parse();
|