@oss-scout/core 0.2.0 → 0.2.1

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 (54) hide show
  1. package/dist/cli.bundle.cjs +42 -42
  2. package/dist/cli.js +110 -86
  3. package/dist/commands/config.d.ts +1 -1
  4. package/dist/commands/config.js +76 -72
  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 +27 -21
  11. package/dist/commands/validation.d.ts +1 -1
  12. package/dist/commands/validation.js +1 -1
  13. package/dist/commands/vet-list.d.ts +2 -2
  14. package/dist/commands/vet-list.js +12 -5
  15. package/dist/commands/vet.d.ts +3 -3
  16. package/dist/commands/vet.js +9 -5
  17. package/dist/core/bootstrap.d.ts +1 -1
  18. package/dist/core/bootstrap.js +20 -16
  19. package/dist/core/category-mapping.d.ts +1 -1
  20. package/dist/core/category-mapping.js +104 -13
  21. package/dist/core/errors.d.ts +8 -1
  22. package/dist/core/errors.js +31 -19
  23. package/dist/core/gist-state-store.d.ts +1 -1
  24. package/dist/core/gist-state-store.js +36 -27
  25. package/dist/core/github.d.ts +1 -1
  26. package/dist/core/github.js +5 -5
  27. package/dist/core/http-cache.js +26 -22
  28. package/dist/core/issue-discovery.d.ts +3 -3
  29. package/dist/core/issue-discovery.js +325 -277
  30. package/dist/core/issue-eligibility.d.ts +2 -2
  31. package/dist/core/issue-eligibility.js +26 -21
  32. package/dist/core/issue-filtering.js +23 -15
  33. package/dist/core/issue-scoring.js +1 -1
  34. package/dist/core/issue-vetting.d.ts +2 -2
  35. package/dist/core/issue-vetting.js +66 -53
  36. package/dist/core/local-state.d.ts +1 -1
  37. package/dist/core/local-state.js +16 -14
  38. package/dist/core/repo-health.d.ts +2 -2
  39. package/dist/core/repo-health.js +46 -35
  40. package/dist/core/schemas.d.ts +1 -1
  41. package/dist/core/schemas.js +40 -18
  42. package/dist/core/search-budget.js +3 -3
  43. package/dist/core/search-phases.d.ts +6 -6
  44. package/dist/core/search-phases.js +23 -19
  45. package/dist/core/types.d.ts +9 -9
  46. package/dist/core/types.js +15 -3
  47. package/dist/core/utils.d.ts +10 -1
  48. package/dist/core/utils.js +44 -25
  49. package/dist/formatters/json.d.ts +1 -1
  50. package/dist/index.d.ts +7 -7
  51. package/dist/index.js +5 -5
  52. package/dist/scout.d.ts +4 -5
  53. package/dist/scout.js +72 -31
  54. package/package.json +1 -1
@@ -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";
@@ -4,21 +4,43 @@
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
+ "orgs",
31
+ "starred",
32
+ "broad",
33
+ "maintained",
34
+ "all",
17
35
  ]);
18
- export const IssueScopeSchema = z.enum(['beginner', 'intermediate', 'advanced']);
19
- export const SearchStrategySchema = z.enum(['merged', 'orgs', 'starred', 'broad', 'maintained', 'all']);
20
36
  /** All concrete strategies (excludes 'all' meta-strategy). */
21
- export const CONCRETE_STRATEGIES = ['merged', 'orgs', 'starred', 'broad', 'maintained'];
37
+ export const CONCRETE_STRATEGIES = [
38
+ "merged",
39
+ "orgs",
40
+ "starred",
41
+ "broad",
42
+ "maintained",
43
+ ];
22
44
  // ── Leaf schemas ────────────────────────────────────────────────────
23
45
  export const RepoSignalsSchema = z.object({
24
46
  hasActiveMaintainers: z.boolean(),
@@ -96,7 +118,7 @@ export const SavedCandidateSchema = z.object({
96
118
  number: z.number(),
97
119
  title: z.string(),
98
120
  labels: z.array(z.string()),
99
- recommendation: z.enum(['approve', 'skip', 'needs_review']),
121
+ recommendation: z.enum(["approve", "skip", "needs_review"]),
100
122
  viabilityScore: z.number(),
101
123
  searchPriority: z.string(),
102
124
  firstSeenAt: z.string(),
@@ -104,22 +126,22 @@ export const SavedCandidateSchema = z.object({
104
126
  lastScore: z.number(),
105
127
  });
106
128
  // ── Scout preferences schema ────────────────────────────────────────
107
- export const PersistenceModeSchema = z.enum(['local', 'gist']);
129
+ export const PersistenceModeSchema = z.enum(["local", "gist"]);
108
130
  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']),
131
+ githubUsername: z.string().default(""),
132
+ languages: z.array(z.string()).default(["typescript", "javascript"]),
133
+ labels: z.array(z.string()).default(["good first issue", "help wanted"]),
112
134
  scope: z.array(IssueScopeSchema).optional(),
113
135
  excludeRepos: z.array(z.string()).default([]),
114
136
  excludeOrgs: z.array(z.string()).default([]),
115
- aiPolicyBlocklist: z.array(z.string()).default(['matplotlib/matplotlib']),
137
+ aiPolicyBlocklist: z.array(z.string()).default(["matplotlib/matplotlib"]),
116
138
  preferredOrgs: z.array(z.string()).default([]),
117
139
  projectCategories: z.array(ProjectCategorySchema).default([]),
118
140
  minStars: z.number().default(50),
119
141
  maxIssueAgeDays: z.number().default(90),
120
142
  includeDocIssues: z.boolean().default(true),
121
143
  minRepoScoreThreshold: z.number().default(4),
122
- persistence: PersistenceModeSchema.default('local'),
144
+ persistence: PersistenceModeSchema.default("local"),
123
145
  defaultStrategy: z.array(SearchStrategySchema).optional(),
124
146
  });
125
147
  // ── Root state schema ───────────────────────────────────────────────
@@ -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" | "preferred_org" | "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;
@@ -1,39 +1,58 @@
1
1
  /**
2
2
  * Shared utility functions for oss-scout.
3
3
  */
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
- import * as os from 'os';
7
- import { execFileSync } from 'child_process';
8
- import { ConfigurationError, errorMessage } from './errors.js';
9
- import { debug } from './logger.js';
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import { execFileSync } from "child_process";
8
+ import { ConfigurationError, errorMessage } from "./errors.js";
9
+ import { debug } from "./logger.js";
10
10
  export function sleep(ms) {
11
11
  return new Promise((resolve) => setTimeout(resolve, ms));
12
12
  }
13
- const MODULE = 'utils';
13
+ const MODULE = "utils";
14
14
  let cachedGitHubToken = null;
15
15
  let tokenFetchAttempted = false;
16
16
  export function getDataDir() {
17
- const dir = path.join(os.homedir(), '.oss-scout');
17
+ const dir = path.join(os.homedir(), ".oss-scout");
18
18
  if (!fs.existsSync(dir)) {
19
19
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
20
20
  }
21
21
  return dir;
22
22
  }
23
23
  export function getCacheDir() {
24
- const dir = path.join(getDataDir(), 'cache');
24
+ const dir = path.join(getDataDir(), "cache");
25
25
  if (!fs.existsSync(dir)) {
26
26
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
27
27
  }
28
28
  return dir;
29
29
  }
30
+ /**
31
+ * Extract "owner/repo" from any GitHub URL format:
32
+ * - https://github.com/owner/repo
33
+ * - https://github.com/owner/repo/pull/123
34
+ * - https://github.com/owner/repo/issues/123
35
+ * - https://api.github.com/repos/owner/repo
36
+ * - https://api.github.com/repos/owner/repo/...
37
+ */
38
+ export function extractRepoFromUrl(url) {
39
+ // API URLs: https://api.github.com/repos/owner/repo[/...]
40
+ const apiMatch = url.match(/api\.github\.com\/repos\/([^/]+\/[^/]+)/);
41
+ if (apiMatch)
42
+ return apiMatch[1];
43
+ // Web URLs: https://github.com/owner/repo[/...]
44
+ const webMatch = url.match(/github\.com\/([^/]+\/[^/]+)/);
45
+ if (webMatch)
46
+ return webMatch[1];
47
+ return null;
48
+ }
30
49
  const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
31
50
  const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
32
51
  function isValidOwnerRepo(owner, repo) {
33
52
  return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
34
53
  }
35
54
  export function parseGitHubUrl(url) {
36
- if (!url.startsWith('https://github.com/'))
55
+ if (!url.startsWith("https://github.com/"))
37
56
  return null;
38
57
  const prMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
39
58
  if (prMatch) {
@@ -41,7 +60,7 @@ export function parseGitHubUrl(url) {
41
60
  const repo = prMatch[2];
42
61
  if (!isValidOwnerRepo(owner, repo))
43
62
  return null;
44
- return { owner, repo, number: parseInt(prMatch[3], 10), type: 'pull' };
63
+ return { owner, repo, number: parseInt(prMatch[3], 10), type: "pull" };
45
64
  }
46
65
  const issueMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
47
66
  if (issueMatch) {
@@ -49,7 +68,7 @@ export function parseGitHubUrl(url) {
49
68
  const repo = issueMatch[2];
50
69
  if (!isValidOwnerRepo(owner, repo))
51
70
  return null;
52
- return { owner, repo, number: parseInt(issueMatch[3], 10), type: 'issues' };
71
+ return { owner, repo, number: parseInt(issueMatch[3], 10), type: "issues" };
53
72
  }
54
73
  return null;
55
74
  }
@@ -58,12 +77,12 @@ export function daysBetween(from, to = new Date()) {
58
77
  }
59
78
  export function getCLIVersion() {
60
79
  try {
61
- const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
62
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
80
+ const pkgPath = path.join(path.dirname(process.argv[1]), "..", "package.json");
81
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version;
63
82
  }
64
83
  catch (err) {
65
84
  debug(MODULE, `Could not read CLI version: ${errorMessage(err)}`);
66
- return 'unknown';
85
+ return "unknown";
67
86
  }
68
87
  }
69
88
  export function getGitHubToken() {
@@ -77,30 +96,30 @@ export function getGitHubToken() {
77
96
  return cachedGitHubToken;
78
97
  }
79
98
  try {
80
- const token = execFileSync('gh', ['auth', 'token'], {
81
- encoding: 'utf-8',
82
- stdio: ['pipe', 'pipe', 'pipe'],
99
+ const token = execFileSync("gh", ["auth", "token"], {
100
+ encoding: "utf-8",
101
+ stdio: ["pipe", "pipe", "pipe"],
83
102
  timeout: 2000,
84
103
  }).trim();
85
104
  if (token && token.length > 0) {
86
105
  cachedGitHubToken = token;
87
- debug(MODULE, 'Using GitHub token from gh CLI');
106
+ debug(MODULE, "Using GitHub token from gh CLI");
88
107
  return cachedGitHubToken;
89
108
  }
90
109
  }
91
110
  catch (err) {
92
- debug(MODULE, 'gh auth token failed', err);
111
+ debug(MODULE, "gh auth token failed", err);
93
112
  }
94
113
  return null;
95
114
  }
96
115
  export function requireGitHubToken() {
97
116
  const token = getGitHubToken();
98
117
  if (!token) {
99
- throw new ConfigurationError('GitHub authentication required.\n\n' +
100
- 'Options:\n' +
101
- ' 1. Use gh CLI: gh auth login\n' +
102
- ' 2. Set GITHUB_TOKEN environment variable\n\n' +
103
- 'The gh CLI is recommended - install from https://cli.github.com');
118
+ throw new ConfigurationError("GitHub authentication required.\n\n" +
119
+ "Options:\n" +
120
+ " 1. Use gh CLI: gh auth login\n" +
121
+ " 2. Set GITHUB_TOKEN environment variable\n\n" +
122
+ "The gh CLI is recommended - install from https://cli.github.com");
104
123
  }
105
124
  return token;
106
125
  }