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