@oss-scout/core 0.2.0 → 0.3.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 (56) hide show
  1. package/dist/cli.bundle.cjs +51 -47
  2. package/dist/cli.js +218 -87
  3. package/dist/commands/config.d.ts +2 -4
  4. package/dist/commands/config.js +76 -78
  5. package/dist/commands/results.d.ts +1 -1
  6. package/dist/commands/results.js +1 -1
  7. package/dist/commands/search.d.ts +2 -2
  8. package/dist/commands/search.js +16 -6
  9. package/dist/commands/setup.d.ts +1 -1
  10. package/dist/commands/setup.js +25 -25
  11. package/dist/commands/skip.d.ts +33 -0
  12. package/dist/commands/skip.js +89 -0
  13. package/dist/commands/validation.d.ts +1 -1
  14. package/dist/commands/validation.js +1 -1
  15. package/dist/commands/vet-list.d.ts +2 -2
  16. package/dist/commands/vet-list.js +12 -5
  17. package/dist/commands/vet.d.ts +3 -3
  18. package/dist/commands/vet.js +9 -5
  19. package/dist/core/bootstrap.d.ts +1 -1
  20. package/dist/core/bootstrap.js +20 -16
  21. package/dist/core/category-mapping.d.ts +1 -1
  22. package/dist/core/category-mapping.js +104 -13
  23. package/dist/core/errors.d.ts +8 -1
  24. package/dist/core/errors.js +31 -19
  25. package/dist/core/gist-state-store.d.ts +1 -1
  26. package/dist/core/gist-state-store.js +55 -28
  27. package/dist/core/github.d.ts +1 -1
  28. package/dist/core/github.js +5 -5
  29. package/dist/core/http-cache.js +26 -22
  30. package/dist/core/issue-discovery.d.ts +6 -6
  31. package/dist/core/issue-discovery.js +279 -286
  32. package/dist/core/issue-eligibility.d.ts +2 -2
  33. package/dist/core/issue-eligibility.js +26 -21
  34. package/dist/core/issue-filtering.js +23 -15
  35. package/dist/core/issue-scoring.js +1 -1
  36. package/dist/core/issue-vetting.d.ts +2 -4
  37. package/dist/core/issue-vetting.js +65 -56
  38. package/dist/core/local-state.d.ts +1 -1
  39. package/dist/core/local-state.js +16 -14
  40. package/dist/core/repo-health.d.ts +2 -2
  41. package/dist/core/repo-health.js +46 -35
  42. package/dist/core/schemas.d.ts +17 -9
  43. package/dist/core/schemas.js +47 -19
  44. package/dist/core/search-budget.js +3 -3
  45. package/dist/core/search-phases.d.ts +6 -6
  46. package/dist/core/search-phases.js +23 -19
  47. package/dist/core/types.d.ts +9 -9
  48. package/dist/core/types.js +15 -3
  49. package/dist/core/utils.d.ts +10 -1
  50. package/dist/core/utils.js +44 -25
  51. package/dist/formatters/json.d.ts +1 -1
  52. package/dist/index.d.ts +7 -7
  53. package/dist/index.js +5 -5
  54. package/dist/scout.d.ts +30 -6
  55. package/dist/scout.js +141 -34
  56. package/package.json +7 -3
@@ -4,11 +4,11 @@
4
4
  * Extracted from issue-vetting.ts to isolate repo-level checks
5
5
  * from issue-level eligibility logic.
6
6
  */
7
- import { daysBetween } from './utils.js';
8
- import { errorMessage } from './errors.js';
9
- import { warn } from './logger.js';
10
- import { getHttpCache, cachedRequest, cachedTimeBased } from './http-cache.js';
11
- const MODULE = 'repo-health';
7
+ import { daysBetween } from "./utils.js";
8
+ import { errorMessage } from "./errors.js";
9
+ import { warn } from "./logger.js";
10
+ import { getHttpCache, cachedRequest, cachedTimeBased } from "./http-cache.js";
11
+ const MODULE = "repo-health";
12
12
  // ── Cache for contribution guidelines ──
13
13
  const guidelinesCache = new Map();
14
14
  /** TTL for cached contribution guidelines (1 hour). */
@@ -57,7 +57,7 @@ export async function checkProjectHealth(octokit, owner, repo) {
57
57
  const lastCommit = commits[0];
58
58
  const lastCommitAt = lastCommit?.commit?.author?.date || repoData.pushed_at;
59
59
  const daysSinceLastCommit = daysBetween(new Date(lastCommitAt));
60
- const ciStatus = 'unknown';
60
+ const ciStatus = "unknown";
61
61
  return {
62
62
  repo: `${owner}/${repo}`,
63
63
  lastCommitAt,
@@ -77,11 +77,11 @@ export async function checkProjectHealth(octokit, owner, repo) {
77
77
  warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
78
78
  return {
79
79
  repo: `${owner}/${repo}`,
80
- lastCommitAt: '',
80
+ lastCommitAt: "",
81
81
  daysSinceLastCommit: 999,
82
82
  openIssuesCount: 0,
83
83
  avgIssueResponseDays: 0,
84
- ciStatus: 'unknown',
84
+ ciStatus: "unknown",
85
85
  isActive: false,
86
86
  checkFailed: true,
87
87
  failureReason: errMsg,
@@ -101,31 +101,41 @@ export async function fetchContributionGuidelines(octokit, owner, repo) {
101
101
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
102
102
  return cached.guidelines;
103
103
  }
104
- const filesToCheck = ['CONTRIBUTING.md', '.github/CONTRIBUTING.md', 'docs/CONTRIBUTING.md', 'contributing.md'];
104
+ const filesToCheck = [
105
+ "CONTRIBUTING.md",
106
+ ".github/CONTRIBUTING.md",
107
+ "docs/CONTRIBUTING.md",
108
+ "contributing.md",
109
+ ];
105
110
  // Probe all paths in parallel — take the first success in priority order
106
111
  const results = await Promise.allSettled(filesToCheck.map((file) => octokit.repos.getContent({ owner, repo, path: file }).then(({ data }) => {
107
- if ('content' in data) {
108
- return Buffer.from(data.content, 'base64').toString('utf-8');
112
+ if ("content" in data) {
113
+ return Buffer.from(data.content, "base64").toString("utf-8");
109
114
  }
110
115
  return null;
111
116
  })));
112
117
  for (let i = 0; i < results.length; i++) {
113
118
  const result = results[i];
114
- if (result.status === 'fulfilled' && result.value) {
119
+ if (result.status === "fulfilled" && result.value) {
115
120
  const guidelines = parseContributionGuidelines(result.value);
116
121
  guidelinesCache.set(cacheKey, { guidelines, fetchedAt: Date.now() });
117
122
  pruneCache();
118
123
  return guidelines;
119
124
  }
120
- if (result.status === 'rejected') {
121
- const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
122
- if (!msg.includes('404') && !msg.includes('Not Found')) {
125
+ if (result.status === "rejected") {
126
+ const msg = result.reason instanceof Error
127
+ ? result.reason.message
128
+ : String(result.reason);
129
+ if (!msg.includes("404") && !msg.includes("Not Found")) {
123
130
  warn(MODULE, `Unexpected error fetching ${filesToCheck[i]} from ${owner}/${repo}: ${msg}`);
124
131
  }
125
132
  }
126
133
  }
127
134
  // Cache the negative result too and prune if needed
128
- guidelinesCache.set(cacheKey, { guidelines: undefined, fetchedAt: Date.now() });
135
+ guidelinesCache.set(cacheKey, {
136
+ guidelines: undefined,
137
+ fetchedAt: Date.now(),
138
+ });
129
139
  pruneCache();
130
140
  return undefined;
131
141
  }
@@ -139,40 +149,41 @@ function parseContributionGuidelines(content) {
139
149
  };
140
150
  const lowerContent = content.toLowerCase();
141
151
  // Detect branch naming conventions
142
- if (lowerContent.includes('branch')) {
152
+ if (lowerContent.includes("branch")) {
143
153
  const branchMatch = content.match(/branch[^\n]*(?:named?|format|convention)[^\n]*[`"]([^`"]+)[`"]/i);
144
154
  if (branchMatch) {
145
155
  guidelines.branchNamingConvention = branchMatch[1];
146
156
  }
147
157
  }
148
158
  // Detect commit message format
149
- if (lowerContent.includes('conventional commit')) {
150
- guidelines.commitMessageFormat = 'conventional commits';
159
+ if (lowerContent.includes("conventional commit")) {
160
+ guidelines.commitMessageFormat = "conventional commits";
151
161
  }
152
- else if (lowerContent.includes('commit message')) {
162
+ else if (lowerContent.includes("commit message")) {
153
163
  const commitMatch = content.match(/commit message[^\n]*[`"]([^`"]+)[`"]/i);
154
164
  if (commitMatch) {
155
165
  guidelines.commitMessageFormat = commitMatch[1];
156
166
  }
157
167
  }
158
168
  // Detect test framework
159
- if (lowerContent.includes('jest'))
160
- guidelines.testFramework = 'Jest';
161
- else if (lowerContent.includes('rspec'))
162
- guidelines.testFramework = 'RSpec';
163
- else if (lowerContent.includes('pytest'))
164
- guidelines.testFramework = 'pytest';
165
- else if (lowerContent.includes('mocha'))
166
- guidelines.testFramework = 'Mocha';
169
+ if (lowerContent.includes("jest"))
170
+ guidelines.testFramework = "Jest";
171
+ else if (lowerContent.includes("rspec"))
172
+ guidelines.testFramework = "RSpec";
173
+ else if (lowerContent.includes("pytest"))
174
+ guidelines.testFramework = "pytest";
175
+ else if (lowerContent.includes("mocha"))
176
+ guidelines.testFramework = "Mocha";
167
177
  // Detect linter
168
- if (lowerContent.includes('eslint'))
169
- guidelines.linter = 'ESLint';
170
- else if (lowerContent.includes('rubocop'))
171
- guidelines.linter = 'RuboCop';
172
- else if (lowerContent.includes('prettier'))
173
- guidelines.formatter = 'Prettier';
178
+ if (lowerContent.includes("eslint"))
179
+ guidelines.linter = "ESLint";
180
+ else if (lowerContent.includes("rubocop"))
181
+ guidelines.linter = "RuboCop";
182
+ else if (lowerContent.includes("prettier"))
183
+ guidelines.formatter = "Prettier";
174
184
  // Detect CLA requirement
175
- if (lowerContent.includes('cla') || lowerContent.includes('contributor license agreement')) {
185
+ if (lowerContent.includes("cla") ||
186
+ lowerContent.includes("contributor license agreement")) {
176
187
  guidelines.claRequired = true;
177
188
  }
178
189
  return guidelines;
@@ -4,7 +4,7 @@
4
4
  * This file is the single source of truth for persisted type shapes.
5
5
  * Types are inferred via `z.infer<>` at the bottom.
6
6
  */
7
- import { z } from 'zod';
7
+ import { z } from "zod";
8
8
  export declare const IssueStatusSchema: z.ZodEnum<{
9
9
  candidate: "candidate";
10
10
  claimed: "claimed";
@@ -27,13 +27,12 @@ export declare const IssueScopeSchema: z.ZodEnum<{
27
27
  export declare const SearchStrategySchema: z.ZodEnum<{
28
28
  all: "all";
29
29
  merged: "merged";
30
- orgs: "orgs";
31
30
  starred: "starred";
32
31
  broad: "broad";
33
32
  maintained: "maintained";
34
33
  }>;
35
34
  /** All concrete strategies (excludes 'all' meta-strategy). */
36
- export declare const CONCRETE_STRATEGIES: readonly ["merged", "orgs", "starred", "broad", "maintained"];
35
+ export declare const CONCRETE_STRATEGIES: readonly ["merged", "starred", "broad", "maintained"];
37
36
  export declare const RepoSignalsSchema: z.ZodObject<{
38
37
  hasActiveMaintainers: z.ZodBoolean;
39
38
  isResponsive: z.ZodBoolean;
@@ -152,6 +151,13 @@ export declare const TrackedIssueSchema: z.ZodObject<{
152
151
  notes: z.ZodArray<z.ZodString>;
153
152
  }, z.core.$strip>>;
154
153
  }, z.core.$strip>;
154
+ export declare const SkippedIssueSchema: z.ZodObject<{
155
+ url: z.ZodString;
156
+ repo: z.ZodString;
157
+ number: z.ZodNumber;
158
+ title: z.ZodString;
159
+ skippedAt: z.ZodString;
160
+ }, z.core.$strip>;
155
161
  export declare const SavedCandidateSchema: z.ZodObject<{
156
162
  issueUrl: z.ZodString;
157
163
  repo: z.ZodString;
@@ -185,7 +191,6 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
185
191
  excludeRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
186
192
  excludeOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
187
193
  aiPolicyBlocklist: z.ZodDefault<z.ZodArray<z.ZodString>>;
188
- preferredOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
189
194
  projectCategories: z.ZodDefault<z.ZodArray<z.ZodEnum<{
190
195
  nonprofit: "nonprofit";
191
196
  devtools: "devtools";
@@ -205,7 +210,6 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
205
210
  defaultStrategy: z.ZodOptional<z.ZodArray<z.ZodEnum<{
206
211
  all: "all";
207
212
  merged: "merged";
208
- orgs: "orgs";
209
213
  starred: "starred";
210
214
  broad: "broad";
211
215
  maintained: "maintained";
@@ -225,7 +229,6 @@ export declare const ScoutStateSchema: z.ZodObject<{
225
229
  excludeRepos: z.ZodDefault<z.ZodArray<z.ZodString>>;
226
230
  excludeOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
227
231
  aiPolicyBlocklist: z.ZodDefault<z.ZodArray<z.ZodString>>;
228
- preferredOrgs: z.ZodDefault<z.ZodArray<z.ZodString>>;
229
232
  projectCategories: z.ZodDefault<z.ZodArray<z.ZodEnum<{
230
233
  nonprofit: "nonprofit";
231
234
  devtools: "devtools";
@@ -245,7 +248,6 @@ export declare const ScoutStateSchema: z.ZodObject<{
245
248
  defaultStrategy: z.ZodOptional<z.ZodArray<z.ZodEnum<{
246
249
  all: "all";
247
250
  merged: "merged";
248
- orgs: "orgs";
249
251
  starred: "starred";
250
252
  broad: "broad";
251
253
  maintained: "maintained";
@@ -296,11 +298,17 @@ export declare const ScoutStateSchema: z.ZodObject<{
296
298
  lastSeenAt: z.ZodString;
297
299
  lastScore: z.ZodNumber;
298
300
  }, z.core.$strip>>>;
301
+ skippedIssues: z.ZodDefault<z.ZodArray<z.ZodObject<{
302
+ url: z.ZodString;
303
+ repo: z.ZodString;
304
+ number: z.ZodNumber;
305
+ title: z.ZodString;
306
+ skippedAt: z.ZodString;
307
+ }, z.core.$strip>>>;
299
308
  lastSearchAt: z.ZodOptional<z.ZodString>;
300
309
  lastRunAt: z.ZodDefault<z.ZodString>;
301
310
  gistId: z.ZodOptional<z.ZodString>;
302
311
  }, z.core.$strip>;
303
- export type IssueStatus = z.infer<typeof IssueStatusSchema>;
304
312
  export type ProjectCategory = z.infer<typeof ProjectCategorySchema>;
305
313
  export type IssueScope = z.infer<typeof IssueScopeSchema>;
306
314
  export type SearchStrategy = z.infer<typeof SearchStrategySchema>;
@@ -311,7 +319,7 @@ export type StoredClosedPR = z.infer<typeof StoredClosedPRSchema>;
311
319
  export type ContributionGuidelines = z.infer<typeof ContributionGuidelinesSchema>;
312
320
  export type IssueVettingResult = z.infer<typeof IssueVettingResultSchema>;
313
321
  export type TrackedIssue = z.infer<typeof TrackedIssueSchema>;
314
- export type PersistenceMode = z.infer<typeof PersistenceModeSchema>;
315
322
  export type ScoutPreferences = z.infer<typeof ScoutPreferencesSchema>;
316
323
  export type SavedCandidate = z.infer<typeof SavedCandidateSchema>;
324
+ export type SkippedIssue = z.infer<typeof SkippedIssueSchema>;
317
325
  export type ScoutState = z.infer<typeof ScoutStateSchema>;
@@ -4,21 +4,41 @@
4
4
  * This file is the single source of truth for persisted type shapes.
5
5
  * Types are inferred via `z.infer<>` at the bottom.
6
6
  */
7
- import { z } from 'zod';
7
+ import { z } from "zod";
8
8
  // ── Enum schemas ────────────────────────────────────────────────────
9
- export const IssueStatusSchema = z.enum(['candidate', 'claimed', 'in_progress', 'pr_submitted']);
9
+ export const IssueStatusSchema = z.enum([
10
+ "candidate",
11
+ "claimed",
12
+ "in_progress",
13
+ "pr_submitted",
14
+ ]);
10
15
  export const ProjectCategorySchema = z.enum([
11
- 'nonprofit',
12
- 'devtools',
13
- 'infrastructure',
14
- 'web-frameworks',
15
- 'data-ml',
16
- 'education',
16
+ "nonprofit",
17
+ "devtools",
18
+ "infrastructure",
19
+ "web-frameworks",
20
+ "data-ml",
21
+ "education",
22
+ ]);
23
+ export const IssueScopeSchema = z.enum([
24
+ "beginner",
25
+ "intermediate",
26
+ "advanced",
27
+ ]);
28
+ export const SearchStrategySchema = z.enum([
29
+ "merged",
30
+ "starred",
31
+ "broad",
32
+ "maintained",
33
+ "all",
17
34
  ]);
18
- export const IssueScopeSchema = z.enum(['beginner', 'intermediate', 'advanced']);
19
- export const SearchStrategySchema = z.enum(['merged', 'orgs', 'starred', 'broad', 'maintained', 'all']);
20
35
  /** All concrete strategies (excludes 'all' meta-strategy). */
21
- export const CONCRETE_STRATEGIES = ['merged', 'orgs', 'starred', 'broad', 'maintained'];
36
+ export const CONCRETE_STRATEGIES = [
37
+ "merged",
38
+ "starred",
39
+ "broad",
40
+ "maintained",
41
+ ];
22
42
  // ── Leaf schemas ────────────────────────────────────────────────────
23
43
  export const RepoSignalsSchema = z.object({
24
44
  hasActiveMaintainers: z.boolean(),
@@ -89,6 +109,14 @@ export const TrackedIssueSchema = z.object({
89
109
  vetted: z.boolean(),
90
110
  vettingResult: IssueVettingResultSchema.optional(),
91
111
  });
112
+ // ── Skipped issue schema ──────────────────────────────────────────
113
+ export const SkippedIssueSchema = z.object({
114
+ url: z.string(),
115
+ repo: z.string(),
116
+ number: z.number(),
117
+ title: z.string(),
118
+ skippedAt: z.string(),
119
+ });
92
120
  // ── Saved candidate schema ─────────────────────────────────────────
93
121
  export const SavedCandidateSchema = z.object({
94
122
  issueUrl: z.string(),
@@ -96,7 +124,7 @@ export const SavedCandidateSchema = z.object({
96
124
  number: z.number(),
97
125
  title: z.string(),
98
126
  labels: z.array(z.string()),
99
- recommendation: z.enum(['approve', 'skip', 'needs_review']),
127
+ recommendation: z.enum(["approve", "skip", "needs_review"]),
100
128
  viabilityScore: z.number(),
101
129
  searchPriority: z.string(),
102
130
  firstSeenAt: z.string(),
@@ -104,22 +132,21 @@ export const SavedCandidateSchema = z.object({
104
132
  lastScore: z.number(),
105
133
  });
106
134
  // ── Scout preferences schema ────────────────────────────────────────
107
- export const PersistenceModeSchema = z.enum(['local', 'gist']);
135
+ export const PersistenceModeSchema = z.enum(["local", "gist"]);
108
136
  export const ScoutPreferencesSchema = z.object({
109
- githubUsername: z.string().default(''),
110
- languages: z.array(z.string()).default(['typescript', 'javascript']),
111
- labels: z.array(z.string()).default(['good first issue', 'help wanted']),
137
+ githubUsername: z.string().default(""),
138
+ languages: z.array(z.string()).default(["any"]),
139
+ labels: z.array(z.string()).default(["good first issue", "help wanted"]),
112
140
  scope: z.array(IssueScopeSchema).optional(),
113
141
  excludeRepos: z.array(z.string()).default([]),
114
142
  excludeOrgs: z.array(z.string()).default([]),
115
- aiPolicyBlocklist: z.array(z.string()).default(['matplotlib/matplotlib']),
116
- preferredOrgs: z.array(z.string()).default([]),
143
+ aiPolicyBlocklist: z.array(z.string()).default(["matplotlib/matplotlib"]),
117
144
  projectCategories: z.array(ProjectCategorySchema).default([]),
118
145
  minStars: z.number().default(50),
119
146
  maxIssueAgeDays: z.number().default(90),
120
147
  includeDocIssues: z.boolean().default(true),
121
148
  minRepoScoreThreshold: z.number().default(4),
122
- persistence: PersistenceModeSchema.default('local'),
149
+ persistence: PersistenceModeSchema.default("local"),
123
150
  defaultStrategy: z.array(SearchStrategySchema).optional(),
124
151
  });
125
152
  // ── Root state schema ───────────────────────────────────────────────
@@ -132,6 +159,7 @@ export const ScoutStateSchema = z.object({
132
159
  mergedPRs: z.array(StoredMergedPRSchema).default([]),
133
160
  closedPRs: z.array(StoredClosedPRSchema).default([]),
134
161
  savedResults: z.array(SavedCandidateSchema).default([]),
162
+ skippedIssues: z.array(SkippedIssueSchema).default([]),
135
163
  lastSearchAt: z.string().optional(),
136
164
  lastRunAt: z.string().default(() => new Date().toISOString()),
137
165
  gistId: z.string().optional(),
@@ -11,9 +11,9 @@
11
11
  * - Call waitForBudget() before making a Search API call to pace requests
12
12
  * - Call canAfford(n) to check if n more calls fit in the remaining budget
13
13
  */
14
- import { debug } from './logger.js';
15
- import { sleep } from './utils.js';
16
- const MODULE = 'search-budget';
14
+ import { debug } from "./logger.js";
15
+ import { sleep } from "./utils.js";
16
+ const MODULE = "search-budget";
17
17
  /** GitHub Search API rate limit: 30 requests per 60-second rolling window. */
18
18
  const SEARCH_RATE_LIMIT = 30;
19
19
  const SEARCH_WINDOW_MS = 60 * 1000;
@@ -4,10 +4,10 @@
4
4
  * Extracted from issue-discovery.ts to isolate search helpers,
5
5
  * caching, spam-filtering, and batched repo search logic.
6
6
  */
7
- import { Octokit } from '@octokit/rest';
8
- import { type SearchPriority, type IssueCandidate, type IssueScope } from './types.js';
9
- import { type GitHubSearchItem } from './issue-filtering.js';
10
- import { IssueVetter } from './issue-vetting.js';
7
+ import { Octokit } from "@octokit/rest";
8
+ import { type SearchPriority, type IssueCandidate, type IssueScope } from "./types.js";
9
+ import { type GitHubSearchItem } from "./issue-filtering.js";
10
+ import { IssueVetter } from "./issue-vetting.js";
11
11
  /** Resolve scope tiers into a flat label list, merged with custom labels. */
12
12
  export declare function buildEffectiveLabels(scopes: IssueScope[], customLabels: string[]): string[];
13
13
  /** Round-robin interleave multiple arrays. */
@@ -19,8 +19,8 @@ export declare function interleaveArrays<T>(arrays: T[][]): T[];
19
19
  */
20
20
  export declare function cachedSearchIssues(octokit: Octokit, params: {
21
21
  q: string;
22
- sort: 'created' | 'updated' | 'comments' | 'reactions' | 'interactions';
23
- order: 'asc' | 'desc';
22
+ sort: "created" | "updated" | "comments" | "reactions" | "interactions";
23
+ order: "asc" | "desc";
24
24
  per_page: number;
25
25
  }): Promise<{
26
26
  total_count: number;
@@ -4,14 +4,14 @@
4
4
  * Extracted from issue-discovery.ts to isolate search helpers,
5
5
  * caching, spam-filtering, and batched repo search logic.
6
6
  */
7
- import { SCOPE_LABELS } from './types.js';
8
- import { errorMessage, isRateLimitError } from './errors.js';
9
- import { debug, warn } from './logger.js';
10
- import { getHttpCache, cachedTimeBased } from './http-cache.js';
11
- import { detectLabelFarmingRepos } from './issue-filtering.js';
12
- import { sleep } from './utils.js';
13
- import { getSearchBudgetTracker } from './search-budget.js';
14
- const MODULE = 'search-phases';
7
+ import { SCOPE_LABELS, } from "./types.js";
8
+ import { errorMessage, isRateLimitError } from "./errors.js";
9
+ import { debug, warn } from "./logger.js";
10
+ import { getHttpCache, cachedTimeBased } from "./http-cache.js";
11
+ import { detectLabelFarmingRepos, } from "./issue-filtering.js";
12
+ import { extractRepoFromUrl, sleep } from "./utils.js";
13
+ import { getSearchBudgetTracker } from "./search-budget.js";
14
+ const MODULE = "search-phases";
15
15
  /** GitHub Search API enforces a max of 5 AND/OR/NOT operators per query. */
16
16
  const GITHUB_MAX_BOOLEAN_OPS = 5;
17
17
  /** Delay between search API calls to avoid GitHub's secondary rate limit (~30 req/min).
@@ -49,10 +49,10 @@ function chunkLabels(labels, reservedOps = 0) {
49
49
  /** Build a GitHub Search API label filter from a list of labels. */
50
50
  function buildLabelQuery(labels) {
51
51
  if (labels.length === 0)
52
- return '';
52
+ return "";
53
53
  if (labels.length === 1)
54
54
  return `label:"${labels[0]}"`;
55
- return `(${labels.map((l) => `label:"${l}"`).join(' OR ')})`;
55
+ return `(${labels.map((l) => `label:"${l}"`).join(" OR ")})`;
56
56
  }
57
57
  /** Resolve scope tiers into a flat label list, merged with custom labels. */
58
58
  export function buildEffectiveLabels(scopes, customLabels) {
@@ -132,8 +132,8 @@ export async function searchWithChunkedLabels(octokit, labels, reservedOps, buil
132
132
  const query = buildQuery(buildLabelQuery(labelChunks[i]));
133
133
  const data = await cachedSearchIssues(octokit, {
134
134
  q: query,
135
- sort: 'created',
136
- order: 'desc',
135
+ sort: "created",
136
+ order: "desc",
137
137
  per_page: perPage,
138
138
  });
139
139
  for (const item of data.items) {
@@ -152,12 +152,14 @@ export async function searchWithChunkedLabels(octokit, labels, reservedOps, buil
152
152
  export async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets, remainingNeeded, minStars, phaseLabel) {
153
153
  const spamRepos = detectLabelFarmingRepos(items);
154
154
  if (spamRepos.size > 0) {
155
- const spamCount = items.filter((i) => spamRepos.has(i.repository_url.split('/').slice(-2).join('/'))).length;
156
- debug(MODULE, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(', ')}`);
155
+ const spamCount = items.filter((i) => spamRepos.has(extractRepoFromUrl(i.repository_url) ?? "")).length;
156
+ debug(MODULE, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(", ")}`);
157
157
  }
158
158
  const itemsToVet = filterIssues(items)
159
159
  .filter((item) => {
160
- const repoFullName = item.repository_url.split('/').slice(-2).join('/');
160
+ const repoFullName = extractRepoFromUrl(item.repository_url);
161
+ if (!repoFullName)
162
+ return false;
161
163
  if (spamRepos.has(repoFullName))
162
164
  return false;
163
165
  return excludedRepoSets.every((s) => !s.has(repoFullName));
@@ -167,7 +169,7 @@ export async function filterVetAndScore(vetter, items, filterIssues, excludedRep
167
169
  debug(MODULE, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
168
170
  return { candidates: [], allVetFailed: false, rateLimitHit: false };
169
171
  }
170
- const { candidates: results, allFailed: allVetFailed, rateLimitHit, } = await vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, 'normal');
172
+ const { candidates: results, allFailed: allVetFailed, rateLimitHit, } = await vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, "normal");
171
173
  const starFiltered = results.filter((c) => {
172
174
  if (c.projectHealth.checkFailed)
173
175
  return true;
@@ -206,10 +208,12 @@ export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labe
206
208
  if (batchIdx > 0)
207
209
  await sleep(INTER_QUERY_DELAY_MS);
208
210
  try {
209
- const repoFilter = batch.map((r) => `repo:${r}`).join(' OR ');
211
+ const repoFilter = batch.map((r) => `repo:${r}`).join(" OR ");
210
212
  const repoOps = batch.length - 1;
211
213
  const perPage = Math.min(30, (maxResults - candidates.length) * 3);
212
- const allItems = await searchWithChunkedLabels(octokit, labels, repoOps, (labelQ) => `${baseQualifiers} ${labelQ} (${repoFilter})`.replace(/ +/g, ' ').trim(), perPage);
214
+ const allItems = await searchWithChunkedLabels(octokit, labels, repoOps, (labelQ) => `${baseQualifiers} ${labelQ} (${repoFilter})`
215
+ .replace(/ +/g, " ")
216
+ .trim(), perPage);
213
217
  if (allItems.length > 0) {
214
218
  const filtered = filterFn(allItems);
215
219
  const remainingNeeded = maxResults - candidates.length;
@@ -224,7 +228,7 @@ export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labe
224
228
  if (isRateLimitError(error)) {
225
229
  rateLimitFailures++;
226
230
  }
227
- const batchReposStr = batch.join(', ');
231
+ const batchReposStr = batch.join(", ");
228
232
  warn(MODULE, `Error searching issues in batch [${batchReposStr}]:`, errorMessage(error));
229
233
  }
230
234
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Core types for oss-scout — ephemeral types that are never persisted.
3
3
  */
4
- import type { RepoSignals, TrackedIssue, IssueVettingResult, IssueScope, ScoutState, SearchStrategy } from './schemas.js';
5
- export type { ProjectCategory, IssueScope, RepoSignals, RepoScore, StoredMergedPR, StoredClosedPR, ContributionGuidelines, IssueVettingResult, TrackedIssue, ScoutPreferences, SavedCandidate, ScoutState, SearchStrategy, } from './schemas.js';
4
+ import type { RepoSignals, TrackedIssue, IssueVettingResult, IssueScope, ScoutState, SearchStrategy } from "./schemas.js";
5
+ export type { ProjectCategory, IssueScope, RepoSignals, RepoScore, StoredMergedPR, StoredClosedPR, ContributionGuidelines, IssueVettingResult, TrackedIssue, ScoutPreferences, SavedCandidate, ScoutState, SearchStrategy, } from "./schemas.js";
6
6
  /** Health snapshot of a GitHub repository. */
7
7
  export interface ProjectHealth {
8
8
  repo: string;
@@ -10,7 +10,7 @@ export interface ProjectHealth {
10
10
  daysSinceLastCommit: number;
11
11
  openIssuesCount: number;
12
12
  avgIssueResponseDays: number;
13
- ciStatus: 'passing' | 'failing' | 'unknown';
13
+ ciStatus: "passing" | "failing" | "unknown";
14
14
  isActive: boolean;
15
15
  stargazersCount?: number;
16
16
  forksCount?: number;
@@ -19,13 +19,13 @@ export interface ProjectHealth {
19
19
  failureReason?: string;
20
20
  }
21
21
  /** Priority tier for issue search results. */
22
- export type SearchPriority = 'merged_pr' | 'preferred_org' | 'starred' | 'normal';
22
+ export type SearchPriority = "merged_pr" | "starred" | "normal";
23
23
  /** A fully vetted issue candidate with scoring. */
24
24
  export interface IssueCandidate {
25
25
  issue: TrackedIssue;
26
26
  vettingResult: IssueVettingResult;
27
27
  projectHealth: ProjectHealth;
28
- recommendation: 'approve' | 'skip' | 'needs_review';
28
+ recommendation: "approve" | "skip" | "needs_review";
29
29
  reasonsToSkip: string[];
30
30
  reasonsToApprove: string[];
31
31
  viabilityScore: number;
@@ -59,8 +59,8 @@ export interface VetListEntry {
59
59
  repo: string;
60
60
  number: number;
61
61
  title: string;
62
- status: 'still_available' | 'claimed' | 'closed' | 'has_pr' | 'error';
63
- recommendation?: 'approve' | 'skip' | 'needs_review';
62
+ status: "still_available" | "claimed" | "closed" | "has_pr" | "error";
63
+ recommendation?: "approve" | "skip" | "needs_review";
64
64
  viabilityScore?: number;
65
65
  errorMessage?: string;
66
66
  }
@@ -84,14 +84,14 @@ export type ScoutConfig = {
84
84
  /** GitHub token with `repo` read scope. Add `gist` scope for persistence. */
85
85
  githubToken: string;
86
86
  /** Use gist-backed persistence (default for standalone CLI). */
87
- persistence?: 'gist';
87
+ persistence?: "gist";
88
88
  /** Gist ID override. Skips gist discovery/creation if provided. */
89
89
  gistId?: string;
90
90
  } | {
91
91
  /** GitHub token with `repo` read scope. */
92
92
  githubToken: string;
93
93
  /** Caller provides state directly. */
94
- persistence: 'provided';
94
+ persistence: "provided";
95
95
  /** Pre-loaded state. Required when persistence is 'provided'. */
96
96
  initialState: ScoutState;
97
97
  };
@@ -3,7 +3,19 @@
3
3
  */
4
4
  // ── Const arrays and mappings ───────────────────────────────────────
5
5
  export const SCOPE_LABELS = {
6
- beginner: ['good first issue', 'help wanted', 'easy', 'up-for-grabs', 'first-timers-only', 'beginner'],
7
- intermediate: ['enhancement', 'feature', 'feature-request', 'contributions welcome'],
8
- advanced: ['proposal', 'RFC', 'accepted', 'design'],
6
+ beginner: [
7
+ "good first issue",
8
+ "help wanted",
9
+ "easy",
10
+ "up-for-grabs",
11
+ "first-timers-only",
12
+ "beginner",
13
+ ],
14
+ intermediate: [
15
+ "enhancement",
16
+ "feature",
17
+ "feature-request",
18
+ "contributions welcome",
19
+ ],
20
+ advanced: ["proposal", "RFC", "accepted", "design"],
9
21
  };
@@ -4,11 +4,20 @@
4
4
  export declare function sleep(ms: number): Promise<void>;
5
5
  export declare function getDataDir(): string;
6
6
  export declare function getCacheDir(): string;
7
+ /**
8
+ * Extract "owner/repo" from any GitHub URL format:
9
+ * - https://github.com/owner/repo
10
+ * - https://github.com/owner/repo/pull/123
11
+ * - https://github.com/owner/repo/issues/123
12
+ * - https://api.github.com/repos/owner/repo
13
+ * - https://api.github.com/repos/owner/repo/...
14
+ */
15
+ export declare function extractRepoFromUrl(url: string): string | null;
7
16
  interface ParsedGitHubUrl {
8
17
  owner: string;
9
18
  repo: string;
10
19
  number: number;
11
- type: 'pull' | 'issues';
20
+ type: "pull" | "issues";
12
21
  }
13
22
  export declare function parseGitHubUrl(url: string): ParsedGitHubUrl | null;
14
23
  export declare function daysBetween(from: Date, to?: Date): number;