@oss-scout/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli.bundle.cjs +114 -0
  2. package/dist/cli.d.ts +5 -0
  3. package/dist/cli.js +341 -0
  4. package/dist/commands/config.d.ts +22 -0
  5. package/dist/commands/config.js +169 -0
  6. package/dist/commands/results.d.ts +8 -0
  7. package/dist/commands/results.js +13 -0
  8. package/dist/commands/search.d.ts +39 -0
  9. package/dist/commands/search.js +50 -0
  10. package/dist/commands/setup.d.ts +17 -0
  11. package/dist/commands/setup.js +104 -0
  12. package/dist/commands/validation.d.ts +6 -0
  13. package/dist/commands/validation.js +17 -0
  14. package/dist/commands/vet-list.d.ts +9 -0
  15. package/dist/commands/vet-list.js +16 -0
  16. package/dist/commands/vet.d.ts +25 -0
  17. package/dist/commands/vet.js +29 -0
  18. package/dist/core/bootstrap.d.ts +14 -0
  19. package/dist/core/bootstrap.js +122 -0
  20. package/dist/core/category-mapping.d.ts +19 -0
  21. package/dist/core/category-mapping.js +58 -0
  22. package/dist/core/concurrency.d.ts +6 -0
  23. package/dist/core/concurrency.js +25 -0
  24. package/dist/core/errors.d.ts +22 -0
  25. package/dist/core/errors.js +69 -0
  26. package/dist/core/gist-state-store.d.ts +96 -0
  27. package/dist/core/gist-state-store.js +302 -0
  28. package/dist/core/github.d.ts +16 -0
  29. package/dist/core/github.js +58 -0
  30. package/dist/core/http-cache.d.ts +108 -0
  31. package/dist/core/http-cache.js +314 -0
  32. package/dist/core/issue-discovery.d.ts +93 -0
  33. package/dist/core/issue-discovery.js +475 -0
  34. package/dist/core/issue-eligibility.d.ts +33 -0
  35. package/dist/core/issue-eligibility.js +151 -0
  36. package/dist/core/issue-filtering.d.ts +51 -0
  37. package/dist/core/issue-filtering.js +103 -0
  38. package/dist/core/issue-scoring.d.ts +43 -0
  39. package/dist/core/issue-scoring.js +97 -0
  40. package/dist/core/issue-vetting.d.ts +44 -0
  41. package/dist/core/issue-vetting.js +270 -0
  42. package/dist/core/local-state.d.ts +16 -0
  43. package/dist/core/local-state.js +56 -0
  44. package/dist/core/logger.d.ts +11 -0
  45. package/dist/core/logger.js +25 -0
  46. package/dist/core/pagination.d.ts +7 -0
  47. package/dist/core/pagination.js +16 -0
  48. package/dist/core/repo-health.d.ts +19 -0
  49. package/dist/core/repo-health.js +179 -0
  50. package/dist/core/schemas.d.ts +315 -0
  51. package/dist/core/schemas.js +137 -0
  52. package/dist/core/search-budget.d.ts +62 -0
  53. package/dist/core/search-budget.js +129 -0
  54. package/dist/core/search-phases.d.ts +69 -0
  55. package/dist/core/search-phases.js +238 -0
  56. package/dist/core/types.d.ts +124 -0
  57. package/dist/core/types.js +9 -0
  58. package/dist/core/utils.d.ts +18 -0
  59. package/dist/core/utils.js +106 -0
  60. package/dist/formatters/json.d.ts +6 -0
  61. package/dist/formatters/json.js +20 -0
  62. package/dist/index.d.ts +23 -0
  63. package/dist/index.js +25 -0
  64. package/dist/scout.d.ts +125 -0
  65. package/dist/scout.js +391 -0
  66. package/package.json +70 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Setup command — interactive first-run configuration for oss-scout.
3
+ */
4
+ import * as readline from 'readline';
5
+ import { execFile } from 'child_process';
6
+ import { ScoutPreferencesSchema, ProjectCategorySchema, IssueScopeSchema } from '../core/schemas.js';
7
+ const ALL_CATEGORIES = ProjectCategorySchema.options;
8
+ const ALL_SCOPES = IssueScopeSchema.options;
9
+ function createReadlineInterface() {
10
+ return readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout,
13
+ });
14
+ }
15
+ function ask(rl, query) {
16
+ return new Promise((resolve) => {
17
+ rl.question(query, (answer) => resolve(answer.trim()));
18
+ });
19
+ }
20
+ function detectGitHubUsername() {
21
+ return new Promise((resolve) => {
22
+ execFile('gh', ['api', 'user', '--jq', '.login'], { timeout: 5000 }, (err, stdout) => {
23
+ if (err || !stdout.trim()) {
24
+ resolve('');
25
+ }
26
+ else {
27
+ resolve(stdout.trim());
28
+ }
29
+ });
30
+ });
31
+ }
32
+ function parseCSV(input) {
33
+ return input
34
+ .split(',')
35
+ .map((s) => s.trim())
36
+ .filter((s) => s.length > 0);
37
+ }
38
+ function parseMultiSelect(input, options) {
39
+ if (!input)
40
+ return [];
41
+ const selected = parseCSV(input);
42
+ return selected.filter((s) => options.includes(s));
43
+ }
44
+ /**
45
+ * Run the interactive setup flow and return the configured preferences.
46
+ */
47
+ export async function runSetup(options) {
48
+ const rl = options?.rl ?? createReadlineInterface();
49
+ const detect = options?.detectUsername ?? detectGitHubUsername;
50
+ try {
51
+ console.log('\n🔧 oss-scout setup\n');
52
+ // Detect GitHub username
53
+ console.log('Detecting GitHub username...');
54
+ const detectedUsername = await detect();
55
+ const usernameDefault = detectedUsername || '';
56
+ const usernamePrompt = detectedUsername
57
+ ? `GitHub username [${detectedUsername}]: `
58
+ : 'GitHub username: ';
59
+ const usernameInput = await ask(rl, usernamePrompt);
60
+ const githubUsername = usernameInput || usernameDefault;
61
+ // Languages
62
+ const defaultLangs = 'typescript, javascript';
63
+ const langsInput = await ask(rl, `Preferred languages [${defaultLangs}]: `);
64
+ const languages = langsInput ? parseCSV(langsInput) : ['typescript', 'javascript'];
65
+ // Issue labels
66
+ const defaultLabels = 'good first issue, help wanted';
67
+ const labelsInput = await ask(rl, `Issue labels to search for [${defaultLabels}]: `);
68
+ const labels = labelsInput ? parseCSV(labelsInput) : ['good first issue', 'help wanted'];
69
+ // Difficulty scope
70
+ const scopeOptions = ALL_SCOPES.join(', ');
71
+ const scopeInput = await ask(rl, `Difficulty scope (${scopeOptions}) [all]: `);
72
+ const scope = scopeInput ? parseMultiSelect(scopeInput, ALL_SCOPES) : [...ALL_SCOPES];
73
+ // Minimum stars
74
+ const minStarsInput = await ask(rl, 'Minimum repo stars [50]: ');
75
+ const minStars = minStarsInput ? parseInt(minStarsInput, 10) : 50;
76
+ // Preferred organizations
77
+ const orgsInput = await ask(rl, 'Preferred organizations (comma-separated, optional): ');
78
+ const preferredOrgs = parseCSV(orgsInput);
79
+ // Project categories
80
+ const categoryOptions = ALL_CATEGORIES.join(', ');
81
+ const categoriesInput = await ask(rl, `Project categories (${categoryOptions}) [none]: `);
82
+ const projectCategories = parseMultiSelect(categoriesInput, ALL_CATEGORIES);
83
+ // Repos to exclude
84
+ const excludeInput = await ask(rl, 'Repos to exclude (owner/repo, comma-separated, optional): ');
85
+ const excludeRepos = parseCSV(excludeInput);
86
+ const prefs = ScoutPreferencesSchema.parse({
87
+ githubUsername,
88
+ languages,
89
+ labels,
90
+ scope: scope.length > 0 ? scope : undefined,
91
+ excludeRepos,
92
+ preferredOrgs,
93
+ projectCategories,
94
+ minStars: isNaN(minStars) ? 50 : minStars,
95
+ });
96
+ console.log('\n✅ Setup complete! Preferences saved.\n');
97
+ return prefs;
98
+ }
99
+ finally {
100
+ if (!options?.rl) {
101
+ rl.close();
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Shared validation patterns and helpers for CLI commands.
3
+ */
4
+ export declare const ISSUE_URL_PATTERN: RegExp;
5
+ export declare function validateGitHubUrl(url: string, pattern: RegExp, entityType: 'issue'): void;
6
+ export declare function validateUrl(url: string): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared validation patterns and helpers for CLI commands.
3
+ */
4
+ import { ValidationError } from '../core/errors.js';
5
+ export const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
6
+ const MAX_URL_LENGTH = 2048;
7
+ export function validateGitHubUrl(url, pattern, entityType) {
8
+ if (pattern.test(url))
9
+ return;
10
+ throw new ValidationError(`Invalid ${entityType} URL: ${url}. Expected format: https://github.com/owner/repo/issues/123`);
11
+ }
12
+ export function validateUrl(url) {
13
+ if (url.length > MAX_URL_LENGTH) {
14
+ throw new ValidationError(`URL exceeds maximum length of ${MAX_URL_LENGTH} characters`);
15
+ }
16
+ return url;
17
+ }
@@ -0,0 +1,9 @@
1
+ import type { ScoutState } from '../core/schemas.js';
2
+ import type { VetListResult } from '../core/types.js';
3
+ interface VetListCommandOptions {
4
+ concurrency?: number;
5
+ prune?: boolean;
6
+ state?: ScoutState;
7
+ }
8
+ export declare function runVetList(options: VetListCommandOptions): Promise<VetListResult>;
9
+ export {};
@@ -0,0 +1,16 @@
1
+ import { createScout } from '../scout.js';
2
+ import { requireGitHubToken } from '../core/utils.js';
3
+ import { saveLocalState } from '../core/local-state.js';
4
+ export async function runVetList(options) {
5
+ const token = requireGitHubToken();
6
+ const scout = options.state
7
+ ? await createScout({ githubToken: token, persistence: 'provided', initialState: options.state })
8
+ : await createScout({ githubToken: token });
9
+ const result = await scout.vetList({
10
+ concurrency: options.concurrency,
11
+ prune: options.prune,
12
+ });
13
+ saveLocalState(scout.getState());
14
+ await scout.checkpoint();
15
+ return result;
16
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Vet command — vets a specific issue for claimability.
3
+ */
4
+ import type { ProjectHealth } from '../core/types.js';
5
+ import type { IssueVettingResult, ScoutState } from '../core/schemas.js';
6
+ export interface VetOutput {
7
+ issue: {
8
+ repo: string;
9
+ number: number;
10
+ title: string;
11
+ url: string;
12
+ labels: string[];
13
+ };
14
+ recommendation: 'approve' | 'skip' | 'needs_review';
15
+ reasonsToApprove: string[];
16
+ reasonsToSkip: string[];
17
+ projectHealth: ProjectHealth;
18
+ vettingResult: IssueVettingResult;
19
+ }
20
+ interface VetCommandOptions {
21
+ issueUrl: string;
22
+ state?: ScoutState;
23
+ }
24
+ export declare function runVet(options: VetCommandOptions): Promise<VetOutput>;
25
+ export {};
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Vet command — vets a specific issue for claimability.
3
+ */
4
+ import { createScout } from '../scout.js';
5
+ import { requireGitHubToken } from '../core/utils.js';
6
+ import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
7
+ export async function runVet(options) {
8
+ validateUrl(options.issueUrl);
9
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
10
+ const token = requireGitHubToken();
11
+ const scout = options.state
12
+ ? await createScout({ githubToken: token, persistence: 'provided', initialState: options.state })
13
+ : await createScout({ githubToken: token });
14
+ const candidate = await scout.vetIssue(options.issueUrl);
15
+ return {
16
+ issue: {
17
+ repo: candidate.issue.repo,
18
+ number: candidate.issue.number,
19
+ title: candidate.issue.title,
20
+ url: candidate.issue.url,
21
+ labels: candidate.issue.labels,
22
+ },
23
+ recommendation: candidate.recommendation,
24
+ reasonsToApprove: candidate.reasonsToApprove,
25
+ reasonsToSkip: candidate.reasonsToSkip,
26
+ projectHealth: candidate.projectHealth,
27
+ vettingResult: candidate.vettingResult,
28
+ };
29
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * First-run bootstrap — fetches starred repos and PR history from GitHub
3
+ * to seed the scout's state with the user's contribution context.
4
+ */
5
+ import type { OssScout } from '../scout.js';
6
+ export interface BootstrapResult {
7
+ starredRepoCount: number;
8
+ mergedPRCount: number;
9
+ closedPRCount: number;
10
+ reposScoredCount: number;
11
+ skippedDueToRateLimit: boolean;
12
+ errors: string[];
13
+ }
14
+ export declare function bootstrapScout(scout: OssScout, token: string): Promise<BootstrapResult>;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * First-run bootstrap — fetches starred repos and PR history from GitHub
3
+ * to seed the scout's state with the user's contribution context.
4
+ */
5
+ import { getOctokit, checkRateLimit } from './github.js';
6
+ import { debug, warn } from './logger.js';
7
+ import { errorMessage } from './errors.js';
8
+ const MODULE = 'bootstrap';
9
+ const STARRED_MAX_PAGES = 5;
10
+ const SEARCH_MAX_PAGES = 3;
11
+ const PER_PAGE = 100;
12
+ export async function bootstrapScout(scout, token) {
13
+ const username = scout.getPreferences().githubUsername;
14
+ if (!username) {
15
+ throw new Error('GitHub username not configured. Run `oss-scout setup` first.');
16
+ }
17
+ const rateLimit = await checkRateLimit(token);
18
+ debug(MODULE, `Rate limit: ${rateLimit.remaining}/${rateLimit.limit}, resets at ${rateLimit.resetAt}`);
19
+ if (rateLimit.remaining < 15) {
20
+ debug(MODULE, 'Insufficient rate limit, skipping bootstrap');
21
+ return {
22
+ starredRepoCount: 0,
23
+ mergedPRCount: 0,
24
+ closedPRCount: 0,
25
+ reposScoredCount: 0,
26
+ skippedDueToRateLimit: true,
27
+ errors: [],
28
+ };
29
+ }
30
+ const octokit = getOctokit(token);
31
+ const errors = [];
32
+ // 1. Fetch starred repos (up to 500)
33
+ const starredRepos = [];
34
+ try {
35
+ let starredPage = 0;
36
+ for await (const response of octokit.paginate.iterator(octokit.activity.listReposStarredByAuthenticatedUser, { per_page: PER_PAGE, headers: { accept: 'application/vnd.github.v3+json' } })) {
37
+ for (const repo of response.data) {
38
+ const r = repo;
39
+ starredRepos.push(r.full_name);
40
+ }
41
+ starredPage++;
42
+ if (starredPage >= STARRED_MAX_PAGES)
43
+ break;
44
+ }
45
+ debug(MODULE, `Fetched ${starredRepos.length} starred repos`);
46
+ scout.setStarredRepos(starredRepos);
47
+ }
48
+ catch (err) {
49
+ warn(MODULE, `Failed to fetch starred repos: ${errorMessage(err)}`);
50
+ errors.push('starred repos fetch failed');
51
+ }
52
+ // 2. Fetch merged PRs via Search API
53
+ let mergedPRCount = 0;
54
+ try {
55
+ for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
56
+ const { data } = await octokit.search.issuesAndPullRequests({
57
+ q: `is:pr is:merged author:${username}`,
58
+ per_page: PER_PAGE,
59
+ page,
60
+ });
61
+ for (const item of data.items) {
62
+ const repoMatch = item.html_url.match(/github\.com\/([^/]+\/[^/]+)\//);
63
+ if (!repoMatch)
64
+ continue;
65
+ scout.recordMergedPR({
66
+ url: item.html_url,
67
+ title: item.title,
68
+ mergedAt: item.closed_at ?? new Date().toISOString(),
69
+ repo: repoMatch[1],
70
+ });
71
+ mergedPRCount++;
72
+ }
73
+ if (data.items.length < PER_PAGE)
74
+ break;
75
+ }
76
+ debug(MODULE, `Imported ${mergedPRCount} merged PRs`);
77
+ }
78
+ catch (err) {
79
+ warn(MODULE, `Failed to fetch merged PRs: ${errorMessage(err)}`);
80
+ errors.push('merged PR fetch failed');
81
+ }
82
+ // 3. Fetch closed-without-merge PRs via Search API
83
+ let closedPRCount = 0;
84
+ try {
85
+ for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
86
+ const { data } = await octokit.search.issuesAndPullRequests({
87
+ q: `is:pr is:closed is:unmerged author:${username}`,
88
+ per_page: PER_PAGE,
89
+ page,
90
+ });
91
+ for (const item of data.items) {
92
+ const repoMatch = item.html_url.match(/github\.com\/([^/]+\/[^/]+)\//);
93
+ if (!repoMatch)
94
+ continue;
95
+ scout.recordClosedPR({
96
+ url: item.html_url,
97
+ title: item.title,
98
+ closedAt: item.closed_at ?? new Date().toISOString(),
99
+ repo: repoMatch[1],
100
+ });
101
+ closedPRCount++;
102
+ }
103
+ if (data.items.length < PER_PAGE)
104
+ break;
105
+ }
106
+ debug(MODULE, `Imported ${closedPRCount} closed PRs`);
107
+ }
108
+ catch (err) {
109
+ warn(MODULE, `Failed to fetch closed PRs: ${errorMessage(err)}`);
110
+ errors.push('closed PR fetch failed');
111
+ }
112
+ const state = scout.getState();
113
+ const reposScoredCount = Object.keys(state.repoScores).length;
114
+ return {
115
+ starredRepoCount: starredRepos.length,
116
+ mergedPRCount,
117
+ closedPRCount,
118
+ reposScoredCount,
119
+ skippedDueToRateLimit: false,
120
+ errors,
121
+ };
122
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Category Mapping — static mappings from project categories to GitHub topics and organizations.
3
+ *
4
+ * Used by issue discovery to prioritize repos matching user's category preferences.
5
+ */
6
+ import type { ProjectCategory } from './types.js';
7
+ /** GitHub topics associated with each project category, used for `topic:` search queries. */
8
+ export declare const CATEGORY_TOPICS: Record<ProjectCategory, string[]>;
9
+ /** Well-known GitHub organizations associated with each project category. */
10
+ export declare const CATEGORY_ORGS: Record<ProjectCategory, string[]>;
11
+ /**
12
+ * Check if a repo belongs to any of the given categories based on its owner matching a category org.
13
+ * Comparison is case-insensitive.
14
+ */
15
+ export declare function repoBelongsToCategory(repoFullName: string, categories: ProjectCategory[]): boolean;
16
+ /**
17
+ * Get deduplicated GitHub topics for the given categories, for use in `topic:` search queries.
18
+ */
19
+ export declare function getTopicsForCategories(categories: ProjectCategory[]): string[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Category Mapping — static mappings from project categories to GitHub topics and organizations.
3
+ *
4
+ * Used by issue discovery to prioritize repos matching user's category preferences.
5
+ */
6
+ /** GitHub topics associated with each project category, used for `topic:` search queries. */
7
+ export const CATEGORY_TOPICS = {
8
+ nonprofit: ['nonprofit', 'social-good', 'humanitarian', 'charity', 'social-impact', 'civic-tech'],
9
+ devtools: ['developer-tools', 'devtools', 'cli', 'sdk', 'linter', 'formatter', 'build-tool'],
10
+ infrastructure: ['infrastructure', 'cloud', 'kubernetes', 'docker', 'devops', 'monitoring', 'observability'],
11
+ 'web-frameworks': ['web-framework', 'frontend', 'backend', 'fullstack', 'nextjs', 'react', 'vue'],
12
+ 'data-ml': ['machine-learning', 'data-science', 'deep-learning', 'nlp', 'data-pipeline', 'analytics'],
13
+ education: ['education', 'learning', 'tutorial', 'courseware', 'edtech', 'teaching'],
14
+ };
15
+ /** Well-known GitHub organizations associated with each project category. */
16
+ export const CATEGORY_ORGS = {
17
+ nonprofit: ['code-for-america', 'opengovfoundation', 'ushahidi', 'hotosm', 'openfn', 'democracyearth'],
18
+ devtools: ['eslint', 'prettier', 'vitejs', 'biomejs', 'oxc-project', 'ast-grep', 'turbot'],
19
+ infrastructure: ['kubernetes', 'hashicorp', 'grafana', 'prometheus', 'open-telemetry', 'envoyproxy', 'cncf'],
20
+ 'web-frameworks': ['vercel', 'remix-run', 'sveltejs', 'nuxt', 'astro', 'redwoodjs', 'blitz-js'],
21
+ 'data-ml': ['huggingface', 'mlflow', 'apache', 'dbt-labs', 'dagster-io', 'prefecthq', 'langchain-ai'],
22
+ education: ['freeCodeCamp', 'TheOdinProject', 'exercism', 'codecademy', 'oppia', 'Khan'],
23
+ };
24
+ /**
25
+ * Check if a repo belongs to any of the given categories based on its owner matching a category org.
26
+ * Comparison is case-insensitive.
27
+ */
28
+ export function repoBelongsToCategory(repoFullName, categories) {
29
+ if (categories.length === 0)
30
+ return false;
31
+ const owner = repoFullName.split('/')[0]?.toLowerCase();
32
+ if (!owner)
33
+ return false;
34
+ for (const category of categories) {
35
+ const orgs = CATEGORY_ORGS[category];
36
+ if (!orgs)
37
+ continue; // Guard against invalid categories from untrusted input
38
+ if (orgs.some((org) => org.toLowerCase() === owner)) {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+ /**
45
+ * Get deduplicated GitHub topics for the given categories, for use in `topic:` search queries.
46
+ */
47
+ export function getTopicsForCategories(categories) {
48
+ const topics = new Set();
49
+ for (const category of categories) {
50
+ const categoryTopics = CATEGORY_TOPICS[category];
51
+ if (!categoryTopics)
52
+ continue; // Guard against invalid categories from untrusted input
53
+ for (const topic of categoryTopics) {
54
+ topics.add(topic);
55
+ }
56
+ }
57
+ return [...topics];
58
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Runs a worker pool that processes items with bounded concurrency.
3
+ * N workers consume from a shared index. On any worker error, remaining
4
+ * workers are aborted via a shared flag and the error is propagated.
5
+ */
6
+ export declare function runWorkerPool<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runs a worker pool that processes items with bounded concurrency.
3
+ * N workers consume from a shared index. On any worker error, remaining
4
+ * workers are aborted via a shared flag and the error is propagated.
5
+ */
6
+ export async function runWorkerPool(items, worker, concurrency) {
7
+ let index = 0;
8
+ let aborted = false;
9
+ const poolWorker = async () => {
10
+ while (index < items.length) {
11
+ if (aborted)
12
+ break;
13
+ const item = items[index++];
14
+ try {
15
+ await worker(item);
16
+ }
17
+ catch (err) {
18
+ aborted = true;
19
+ throw err;
20
+ }
21
+ }
22
+ };
23
+ const workerCount = Math.min(concurrency, items.length);
24
+ await Promise.all(Array.from({ length: workerCount }, () => poolWorker()));
25
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Custom error type hierarchy for oss-scout.
3
+ */
4
+ export declare class OssScoutError extends Error {
5
+ readonly code: string;
6
+ constructor(message: string, code: string);
7
+ }
8
+ export declare class ConfigurationError extends OssScoutError {
9
+ constructor(message: string);
10
+ }
11
+ export declare class ValidationError extends OssScoutError {
12
+ constructor(message: string);
13
+ }
14
+ export declare function errorMessage(e: unknown): string;
15
+ export declare function getHttpStatusCode(error: unknown): number | undefined;
16
+ export declare function isRateLimitError(error: unknown): boolean;
17
+ /** Error codes for JSON output. */
18
+ export type ErrorCode = 'AUTH_REQUIRED' | 'CONFIGURATION' | 'NETWORK' | 'NOT_FOUND' | 'RATE_LIMITED' | 'STATE_CORRUPTED' | 'UNKNOWN' | 'VALIDATION';
19
+ /**
20
+ * Map an unknown error to a structured ErrorCode for JSON output.
21
+ */
22
+ export declare function resolveErrorCode(err: unknown): ErrorCode;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Custom error type hierarchy for oss-scout.
3
+ */
4
+ export class OssScoutError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = 'OssScoutError';
10
+ }
11
+ }
12
+ export class ConfigurationError extends OssScoutError {
13
+ constructor(message) {
14
+ super(message, 'CONFIGURATION_ERROR');
15
+ this.name = 'ConfigurationError';
16
+ }
17
+ }
18
+ export class ValidationError extends OssScoutError {
19
+ constructor(message) {
20
+ super(message, 'VALIDATION_ERROR');
21
+ this.name = 'ValidationError';
22
+ }
23
+ }
24
+ export function errorMessage(e) {
25
+ return e instanceof Error ? e.message : String(e);
26
+ }
27
+ export function getHttpStatusCode(error) {
28
+ if (error && typeof error === 'object' && 'status' in error) {
29
+ const status = error.status;
30
+ return typeof status === 'number' && Number.isFinite(status) ? status : undefined;
31
+ }
32
+ return undefined;
33
+ }
34
+ export function isRateLimitError(error) {
35
+ const status = getHttpStatusCode(error);
36
+ if (status === 429)
37
+ return true;
38
+ if (status === 403) {
39
+ const msg = errorMessage(error).toLowerCase();
40
+ return msg.includes('rate limit');
41
+ }
42
+ return false;
43
+ }
44
+ /**
45
+ * Map an unknown error to a structured ErrorCode for JSON output.
46
+ */
47
+ export function resolveErrorCode(err) {
48
+ if (err instanceof ConfigurationError)
49
+ return 'CONFIGURATION';
50
+ if (err instanceof ValidationError)
51
+ return 'VALIDATION';
52
+ const status = getHttpStatusCode(err);
53
+ if (status === 401)
54
+ return 'AUTH_REQUIRED';
55
+ if (status === 403) {
56
+ const msg = errorMessage(err).toLowerCase();
57
+ if (msg.includes('rate limit') || msg.includes('abuse detection'))
58
+ return 'RATE_LIMITED';
59
+ return 'AUTH_REQUIRED';
60
+ }
61
+ if (status === 404)
62
+ return 'NOT_FOUND';
63
+ if (status === 429)
64
+ return 'RATE_LIMITED';
65
+ const msg = errorMessage(err).toLowerCase();
66
+ if (msg.includes('enotfound') || msg.includes('econnrefused') || msg.includes('etimedout') || msg.includes('fetch failed'))
67
+ return 'NETWORK';
68
+ return 'UNKNOWN';
69
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Gist-backed state persistence for oss-scout.
3
+ *
4
+ * Stores ScoutState as a private GitHub Gist, with a local file cache
5
+ * as fallback when the API is unavailable.
6
+ */
7
+ import type { ScoutState } from './schemas.js';
8
+ /** Minimal Octokit interface for gist operations — keeps the class testable. */
9
+ export interface GistOctokitLike {
10
+ gists: {
11
+ get(params: {
12
+ gist_id: string;
13
+ }): Promise<{
14
+ data: {
15
+ id: string;
16
+ files: Record<string, {
17
+ content?: string;
18
+ } | undefined> | null;
19
+ };
20
+ }>;
21
+ create(params: {
22
+ description: string;
23
+ public: boolean;
24
+ files: Record<string, {
25
+ content: string;
26
+ }>;
27
+ }): Promise<{
28
+ data: {
29
+ id: string;
30
+ };
31
+ }>;
32
+ update(params: {
33
+ gist_id: string;
34
+ files: Record<string, {
35
+ content: string;
36
+ }>;
37
+ }): Promise<{
38
+ data: {
39
+ id: string;
40
+ };
41
+ }>;
42
+ list(params: {
43
+ per_page: number;
44
+ page: number;
45
+ }): Promise<{
46
+ data: Array<{
47
+ id: string;
48
+ description: string | null;
49
+ }>;
50
+ }>;
51
+ };
52
+ }
53
+ export interface BootstrapResult {
54
+ gistId: string;
55
+ state: ScoutState;
56
+ created: boolean;
57
+ degraded?: boolean;
58
+ }
59
+ export declare class GistStateStore {
60
+ private octokit;
61
+ private gistId;
62
+ constructor(octokit: GistOctokitLike);
63
+ /**
64
+ * Bootstrap: find an existing gist or create a new one.
65
+ * Falls back to local cache if the API is unavailable.
66
+ */
67
+ bootstrap(): Promise<BootstrapResult>;
68
+ /**
69
+ * Push state to the gist. Also writes to local cache as fallback.
70
+ */
71
+ push(state: ScoutState): Promise<boolean>;
72
+ /**
73
+ * Pull state from the gist and merge with local state.
74
+ */
75
+ pull(): Promise<ScoutState | null>;
76
+ /** Get the current gist ID (if known). */
77
+ getGistId(): string | null;
78
+ private bootstrapFromApi;
79
+ private bootstrapFromCache;
80
+ private fetchGistState;
81
+ private searchForGist;
82
+ private createGist;
83
+ private readCachedGistId;
84
+ private saveGistId;
85
+ private readCache;
86
+ private writeCache;
87
+ }
88
+ /**
89
+ * Merge two ScoutState objects with conflict resolution:
90
+ * - repoScores: per-repo, keep the one with more total PR activity
91
+ * - mergedPRs/closedPRs: union by URL
92
+ * - preferences: remote wins
93
+ * - starredRepos: keep the list with the fresher timestamp
94
+ * - savedResults: union by issueUrl, keep newer lastSeenAt
95
+ */
96
+ export declare function mergeStates(local: ScoutState, remote: ScoutState): ScoutState;