sync: multi-branch scanning, discovery strategies, UTF-8 fix, curation improvements

- Add multi-branch scanning to discover skills on non-default branches
- Add popular repos and commits search discovery strategies
- Fix UTF-8 encoding sanitization in skill parser
- Add repo_created_at for threshold-based duplicate tie-breaking
- Add indexer tests and vitest config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-22 04:16:35 +03:30
parent 3f599a23fb
commit b05cb21a65
20 changed files with 1314 additions and 64 deletions

View File

@@ -184,6 +184,7 @@ Always review skill source code before installing.
---
## License
MIT - See [LICENSE](./LICENSE) for details.

View File

@@ -31,18 +31,19 @@ Sentry.init({
// Filter out common non-actionable errors
ignoreErrors: [
// AbortError during navigation is expected browser behavior when
// Next.js RSC prefetch/streaming requests get cancelled mid-flight
"AbortError: BodyStreamBuffer was aborted",
// AbortError during navigation — Next.js RSC prefetch/streaming cancelled mid-flight
"AbortError",
// Network stream errors (Firefox)
"Error in input stream",
// Network fetch failures (Safari / mobile)
"Load failed",
// Browser extension noise (translation/accessibility extensions)
/Object Not Found Matching Id/,
// ResizeObserver loop limit — browser quirk, not actionable
"ResizeObserver",
],
beforeSend(event, hint) {
// Ignore ResizeObserver errors (browser quirk, not actionable)
if (event.exception?.values?.[0]?.value?.includes("ResizeObserver")) {
return null;
}
// Log to console in development for debugging
if (process.env.NODE_ENV === "development") {
console.error("[Sentry]", hint.originalException || hint.syntheticException);

View File

@@ -16,6 +16,21 @@ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
const MAX_DESCRIPTION_LENGTH = 1024;
const MAX_NAME_LENGTH = 64;
/**
* Sanitize a string to be valid UTF-8 for JSONB storage.
* Removes incomplete multi-byte sequences and control characters
* that PostgreSQL's jsonb_build_object rejects.
*/
function sanitizeUtf8(input: string): string {
// Remove null bytes and C0 control characters (except \t \n \r)
let result = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
// Encode to buffer and decode back to strip invalid sequences
result = Buffer.from(result, 'utf8').toString('utf8');
// Replace lone surrogates (U+D800-U+DFFF) which are invalid in UTF-8
result = result.replace(/[\uD800-\uDFFF]/g, '');
return result;
}
/**
* File patterns for discovering instruction files on GitHub
*/
@@ -117,8 +132,8 @@ export function parseSkillMd(content: string): ParsedSkill {
// Build metadata
const metadata: SkillMetadata = {
name: String(frontmatter.name || ''),
description: String(frontmatter.description || ''),
name: sanitizeUtf8(String(frontmatter.name || '')),
description: sanitizeUtf8(String(frontmatter.description || '')),
version: frontmatter.version ? String(frontmatter.version) : undefined,
license: frontmatter.license ? String(frontmatter.license) : undefined,
author: frontmatter.author ? String(frontmatter.author) : undefined,
@@ -387,8 +402,8 @@ export function parseGenericInstructionFile(
const inferredPlatform = pattern?.inferredPlatform || 'claude';
const metadata: SkillMetadata = {
name: derivedName,
description: derivedDescription.slice(0, MAX_DESCRIPTION_LENGTH),
name: sanitizeUtf8(derivedName),
description: sanitizeUtf8(derivedDescription.slice(0, MAX_DESCRIPTION_LENGTH)),
version: frontmatter.version ? String(frontmatter.version) : undefined,
license: frontmatter.license ? String(frontmatter.license) : undefined,
author: frontmatter.author ? String(frontmatter.author) : repoMeta.owner,

View File

@@ -0,0 +1,256 @@
import { describe, it, expect } from 'vitest';
/**
* Threshold-based tie-breaker logic for duplicate detection (T074b).
* These tests validate the decision logic encoded in the SQL query.
*/
const REPO_AGE_THRESHOLD_DAYS = 75;
interface TieBreakCandidate {
id: string;
skillType: 'standalone' | 'collection' | 'aggregator';
repoCreatedAt: Date | null;
githubStars: number;
githubForks: number;
createdAt: Date;
}
/**
* Determine if candidate `a` beats candidate `b` (i.e., `b` should be marked duplicate).
* Returns true if `a` is the canonical (original) skill.
* This mirrors the SQL tie-breaker logic in runPostCrawlCuration().
*/
function isCanonical(a: TieBreakCandidate, b: TieBreakCandidate): boolean {
// Tier 1: standalone/collection beats aggregator
if (a.skillType !== 'aggregator' && b.skillType === 'aggregator') return true;
if (a.skillType === 'aggregator' && b.skillType !== 'aggregator') return false;
// Only compare same-type from here
const sameType = (a.skillType === 'aggregator') === (b.skillType === 'aggregator');
if (!sameType) return false;
// Tier 2: significant age gap (>75 days) → older repo wins
if (a.repoCreatedAt && b.repoCreatedAt) {
const gapMs = Math.abs(a.repoCreatedAt.getTime() - b.repoCreatedAt.getTime());
const gapDays = gapMs / (1000 * 60 * 60 * 24);
if (gapDays > REPO_AGE_THRESHOLD_DAYS) {
return a.repoCreatedAt < b.repoCreatedAt;
}
}
// Tier 3: more stars
if (a.githubStars !== b.githubStars) return a.githubStars > b.githubStars;
// Tier 4: more forks
if (a.githubForks !== b.githubForks) return a.githubForks > b.githubForks;
// Tier 5: older repo_created_at (minor difference)
if (a.repoCreatedAt && b.repoCreatedAt && a.repoCreatedAt.getTime() !== b.repoCreatedAt.getTime()) {
return a.repoCreatedAt < b.repoCreatedAt;
}
// Tier 6: older created_at (SkillHub indexing date)
return a.createdAt < b.createdAt;
}
describe('Duplicate Tie-Breaker Logic', () => {
const baseDate = new Date('2024-01-01');
const daysAgo = (days: number) => new Date(baseDate.getTime() - days * 86400000);
describe('Tier 1: standalone vs aggregator', () => {
it('standalone beats aggregator regardless of stars', () => {
const standalone: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(10),
githubStars: 5, githubForks: 0, createdAt: daysAgo(1),
};
const aggregator: TieBreakCandidate = {
id: 'b', skillType: 'aggregator', repoCreatedAt: daysAgo(200),
githubStars: 5000, githubForks: 500, createdAt: daysAgo(100),
};
expect(isCanonical(standalone, aggregator)).toBe(true);
expect(isCanonical(aggregator, standalone)).toBe(false);
});
it('collection beats aggregator', () => {
const collection: TieBreakCandidate = {
id: 'a', skillType: 'collection', repoCreatedAt: daysAgo(10),
githubStars: 10, githubForks: 1, createdAt: daysAgo(1),
};
const aggregator: TieBreakCandidate = {
id: 'b', skillType: 'aggregator', repoCreatedAt: daysAgo(300),
githubStars: 1000, githubForks: 100, createdAt: daysAgo(200),
};
expect(isCanonical(collection, aggregator)).toBe(true);
});
});
describe('Tier 2: significant age gap (>75 days)', () => {
it('older repo wins when gap > 75 days', () => {
const older: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(200),
githubStars: 10, githubForks: 1, createdAt: daysAgo(5),
};
const newer: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: daysAgo(50),
githubStars: 500, githubForks: 50, createdAt: daysAgo(3),
};
// Gap = 150 days > 75 → older wins despite fewer stars
expect(isCanonical(older, newer)).toBe(true);
expect(isCanonical(newer, older)).toBe(false);
});
it('does NOT apply when gap <= 75 days', () => {
const olderButClose: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(100),
githubStars: 10, githubForks: 1, createdAt: daysAgo(5),
};
const newerButMoreStars: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: daysAgo(50),
githubStars: 500, githubForks: 50, createdAt: daysAgo(3),
};
// Gap = 50 days <= 75 → falls through to Tier 3 (stars)
expect(isCanonical(newerButMoreStars, olderButClose)).toBe(true);
});
it('exactly 75 days falls through to stars', () => {
const a: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(100),
githubStars: 10, githubForks: 0, createdAt: daysAgo(5),
};
const b: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: daysAgo(25),
githubStars: 50, githubForks: 0, createdAt: daysAgo(3),
};
// Gap = exactly 75 days → NOT > 75, so falls to stars
expect(isCanonical(b, a)).toBe(true);
});
});
describe('Tier 3-4: stars and forks (close dates or NULL)', () => {
it('more stars wins when dates are close', () => {
const moreStars: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(100),
githubStars: 500, githubForks: 10, createdAt: daysAgo(5),
};
const fewerStars: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: daysAgo(80),
githubStars: 50, githubForks: 5, createdAt: daysAgo(3),
};
// Gap = 20 days < 75 → stars decide
expect(isCanonical(moreStars, fewerStars)).toBe(true);
});
it('more stars wins when both repo_created_at are NULL', () => {
const moreStars: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: null,
githubStars: 100, githubForks: 10, createdAt: daysAgo(30),
};
const fewerStars: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: null,
githubStars: 50, githubForks: 5, createdAt: daysAgo(20),
};
expect(isCanonical(moreStars, fewerStars)).toBe(true);
});
it('one NULL one not → skip Tier 2, use stars', () => {
const withDate: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(200),
githubStars: 10, githubForks: 0, createdAt: daysAgo(5),
};
const noDate: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: null,
githubStars: 500, githubForks: 50, createdAt: daysAgo(30),
};
// One is NULL → can't compare age → falls to stars
expect(isCanonical(noDate, withDate)).toBe(true);
});
it('forks break ties when stars are equal', () => {
const moreForks: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(50),
githubStars: 100, githubForks: 30, createdAt: daysAgo(5),
};
const fewerForks: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: daysAgo(40),
githubStars: 100, githubForks: 10, createdAt: daysAgo(3),
};
expect(isCanonical(moreForks, fewerForks)).toBe(true);
});
});
describe('Tier 5-6: final tie-breakers', () => {
it('older repo_created_at wins when stars and forks are equal', () => {
const older: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: daysAgo(60),
githubStars: 100, githubForks: 10, createdAt: daysAgo(5),
};
const newer: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: daysAgo(30),
githubStars: 100, githubForks: 10, createdAt: daysAgo(3),
};
// Gap = 30 days < 75 → stars equal, forks equal → Tier 5: older repo wins
expect(isCanonical(older, newer)).toBe(true);
});
it('older created_at wins when everything else is equal or NULL', () => {
const olderIndex: TieBreakCandidate = {
id: 'a', skillType: 'standalone', repoCreatedAt: null,
githubStars: 100, githubForks: 10, createdAt: daysAgo(30),
};
const newerIndex: TieBreakCandidate = {
id: 'b', skillType: 'standalone', repoCreatedAt: null,
githubStars: 100, githubForks: 10, createdAt: daysAgo(5),
};
// All NULL/equal → Tier 6: SkillHub index date
expect(isCanonical(olderIndex, newerIndex)).toBe(true);
});
});
describe('Real-world scenarios', () => {
it('creator wins over older aggregator repo with more stars', () => {
const creator: TieBreakCandidate = {
id: 'creator/tool/skill', skillType: 'standalone',
repoCreatedAt: new Date('2024-06-01'), githubStars: 50,
githubForks: 5, createdAt: daysAgo(30),
};
const aggregator: TieBreakCandidate = {
id: 'collector/awesome/skill', skillType: 'aggregator',
repoCreatedAt: new Date('2020-01-01'), githubStars: 2000,
githubForks: 200, createdAt: daysAgo(60),
};
// Tier 1: standalone beats aggregator
expect(isCanonical(creator, aggregator)).toBe(true);
});
it('old repo copying recently still wins with >75 day gap (accepted trade-off)', () => {
const oldCopier: TieBreakCandidate = {
id: 'old/project/skill', skillType: 'standalone',
repoCreatedAt: new Date('2020-01-01'), githubStars: 30,
githubForks: 2, createdAt: daysAgo(10),
};
const originalCreator: TieBreakCandidate = {
id: 'new/creator/skill', skillType: 'standalone',
repoCreatedAt: new Date('2024-11-01'), githubStars: 100,
githubForks: 15, createdAt: daysAgo(30),
};
// Gap > 75 days → old repo wins (accepted rare trade-off)
expect(isCanonical(oldCopier, originalCreator)).toBe(true);
});
it('recent copier loses to original when gap < 75 days', () => {
const original: TieBreakCandidate = {
id: 'original/skill/md', skillType: 'standalone',
repoCreatedAt: new Date('2024-10-01'), githubStars: 200,
githubForks: 20, createdAt: daysAgo(60),
};
const copier: TieBreakCandidate = {
id: 'copier/skill/md', skillType: 'standalone',
repoCreatedAt: new Date('2024-11-01'), githubStars: 50,
githubForks: 3, createdAt: daysAgo(30),
};
// Gap = 31 days < 75 → stars decide → original (200) > copier (50)
expect(isCanonical(original, copier)).toBe(true);
});
});
});

View File

@@ -37,6 +37,9 @@ type DB = PostgresJsDatabase<typeof schema>;
*/
const browseReadyFilter = sql`(${skills.isDuplicate} = false)`;
/** Minimum repo age gap (in days) to trust repo_created_at over stars for duplicate detection */
const REPO_AGE_THRESHOLD_DAYS = 75;
/**
* Skill queries
*/
@@ -613,6 +616,7 @@ export const skillQueries = {
triggers: skill.triggers,
githubStars: skill.githubStars,
githubForks: skill.githubForks,
repoCreatedAt: skill.repoCreatedAt,
securityScore: skill.securityScore,
securityStatus: skill.securityStatus,
qualityScore: skill.qualityScore,
@@ -1196,7 +1200,13 @@ export async function runPostCrawlCuration(db: DB): Promise<{ classified: number
`);
results.classified = (classResult as unknown as { rowCount: number }).rowCount ?? 0;
// Step 4: Mark duplicates by content_hash (keep canonical = most stars, prioritizing standalone over aggregator)
// Step 4: Mark duplicates by content_hash with 6-tier tie-breaker
// Tier 1: standalone/collection > aggregator
// Tier 2: repo age gap > 75 days → older repo wins (likely original creator)
// Tier 3: more stars (when age gap small or NULL)
// Tier 4: more forks
// Tier 5: older repo_created_at (minor gap)
// Tier 6: older created_at (fallback for NULL repo_created_at)
const dupResult = await db.execute(sql`
UPDATE skills s SET is_duplicate = true
WHERE s.is_blocked = false
@@ -1208,13 +1218,34 @@ export async function runPostCrawlCuration(db: DB): Promise<{ classified: number
AND s2.is_blocked = false
AND s2.id != s.id
AND (
-- Tier 1: standalone/collection beats aggregator
(s2.skill_type != 'aggregator' AND s.skill_type = 'aggregator')
OR (
(s2.skill_type = 'aggregator') = (s.skill_type = 'aggregator')
AND (
s2.github_stars > s.github_stars
OR (s2.github_stars = s.github_stars AND s2.github_forks > s.github_forks)
OR (s2.github_stars = s.github_stars AND s2.github_forks = s.github_forks AND s2.created_at < s.created_at)
-- Tier 2: significant age gap (>75 days) → older repo wins
(s2.repo_created_at IS NOT NULL AND s.repo_created_at IS NOT NULL
AND ABS(EXTRACT(EPOCH FROM (s2.repo_created_at - s.repo_created_at))) > ${REPO_AGE_THRESHOLD_DAYS} * 86400
AND s2.repo_created_at < s.repo_created_at)
-- Tiers 3-6: when age gap <= threshold or NULL
OR (
(s2.repo_created_at IS NULL OR s.repo_created_at IS NULL
OR ABS(EXTRACT(EPOCH FROM (s2.repo_created_at - s.repo_created_at))) <= ${REPO_AGE_THRESHOLD_DAYS} * 86400)
AND (
-- Tier 3: more stars
s2.github_stars > s.github_stars
-- Tier 4: more forks
OR (s2.github_stars = s.github_stars AND s2.github_forks > s.github_forks)
-- Tier 5: older repo_created_at (minor gap)
OR (s2.github_stars = s.github_stars AND s2.github_forks = s.github_forks
AND s2.repo_created_at IS NOT NULL AND s.repo_created_at IS NOT NULL
AND s2.repo_created_at < s.repo_created_at)
-- Tier 6: older created_at (fallback for NULL repo_created_at)
OR (s2.github_stars = s.github_stars AND s2.github_forks = s.github_forks
AND (s2.repo_created_at IS NULL OR s.repo_created_at IS NULL)
AND s2.created_at < s.created_at)
)
)
)
)
)

View File

@@ -102,6 +102,7 @@ export const skills = pgTable(
// Timestamps
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
repoCreatedAt: timestamp('repo_created_at'),
indexedAt: timestamp('indexed_at'),
lastDownloadedAt: timestamp('last_downloaded_at'),
},

View File

@@ -46,6 +46,7 @@ export function createTestSkill(overrides: Partial<SkillInsert> = {}): SkillInse
contentHash: 'abc123',
createdAt: new Date(),
updatedAt: new Date(),
repoCreatedAt: null,
indexedAt: new Date(),
...overrides,
};

3
pnpm-lock.yaml generated
View File

@@ -393,6 +393,9 @@ importers:
typescript:
specifier: ^5.3.0
version: 5.9.3
vitest:
specifier: ^1.2.0
version: 1.6.1(@types/node@20.19.27)(jsdom@27.4.0)(terser@5.46.0)
packages:

View File

@@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS skills (
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
repo_created_at TIMESTAMP WITH TIME ZONE,
indexed_at TIMESTAMP WITH TIME ZONE,
last_downloaded_at TIMESTAMP WITH TIME ZONE
);
@@ -478,6 +479,13 @@ CREATE INDEX IF NOT EXISTS idx_skills_type ON skills(skill_type);
CREATE INDEX IF NOT EXISTS idx_skills_duplicate ON skills(is_duplicate);
CREATE INDEX IF NOT EXISTS idx_skills_content_hash ON skills(content_hash);
-- Review pipeline columns (Phase 4+5 - February 2026)
ALTER TABLE skills ADD COLUMN IF NOT EXISTS review_status TEXT DEFAULT 'unreviewed';
CREATE INDEX IF NOT EXISTS idx_skills_review_status ON skills(review_status);
-- Repo creation date for duplicate tie-breaking (T074b)
ALTER TABLE skills ADD COLUMN IF NOT EXISTS repo_created_at TIMESTAMP WITH TIME ZONE;
-- Grant permissions
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO postgres;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO postgres;

View File

@@ -9,7 +9,8 @@
"worker": "tsx src/worker.ts",
"crawl": "tsx src/crawl.ts",
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest"
},
"dependencies": {
"@octokit/rest": "^20.0.2",
@@ -26,6 +27,7 @@
"@types/node": "^20.10.0",
"tsup": "^8.0.1",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
"typescript": "^5.3.0",
"vitest": "^1.2.0"
}
}

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { parseGitHubRepoUrl } from './crawl.js';
describe('parseGitHubRepoUrl', () => {
it('parses plain repo URL', () => {
const result = parseGitHubRepoUrl('https://github.com/nuxt/ui');
expect(result).toEqual({ owner: 'nuxt', repo: 'ui', branch: null });
});
it('parses URL with /tree/branch', () => {
const result = parseGitHubRepoUrl('https://github.com/nuxt/ui/tree/v4');
expect(result).toEqual({ owner: 'nuxt', repo: 'ui', branch: 'v4' });
});
it('parses URL with /tree/branch and subdirectory path', () => {
const result = parseGitHubRepoUrl('https://github.com/nuxt/ui/tree/v4/skills/nuxt-ui');
expect(result).toEqual({ owner: 'nuxt', repo: 'ui', branch: 'v4' });
});
it('parses URL with trailing slash', () => {
const result = parseGitHubRepoUrl('https://github.com/owner/repo/');
expect(result).toEqual({ owner: 'owner', repo: 'repo', branch: null });
});
it('parses URL with /tree/ but no branch (just tree segment)', () => {
const result = parseGitHubRepoUrl('https://github.com/owner/repo/tree/');
// parts[3] would be undefined since there's no branch after /tree/
expect(result).toEqual({ owner: 'owner', repo: 'repo', branch: null });
});
it('handles http URLs', () => {
const result = parseGitHubRepoUrl('http://github.com/owner/repo');
expect(result).toEqual({ owner: 'owner', repo: 'repo', branch: null });
});
it('handles release branches with slashes in name', () => {
// Note: only first segment after /tree/ is captured as branch
const result = parseGitHubRepoUrl('https://github.com/owner/repo/tree/release');
expect(result).toEqual({ owner: 'owner', repo: 'repo', branch: 'release' });
});
it('throws on URL with only owner', () => {
expect(() => parseGitHubRepoUrl('https://github.com/onlyone')).toThrow('Invalid GitHub URL');
});
it('throws on URL with no path', () => {
expect(() => parseGitHubRepoUrl('https://github.com/')).toThrow('Invalid GitHub URL');
});
it('handles repo URL with blob segment (not tree)', () => {
const result = parseGitHubRepoUrl('https://github.com/owner/repo/blob/main/README.md');
// /blob/ is not /tree/, so no branch extraction
expect(result).toEqual({ owner: 'owner', repo: 'repo', branch: null });
});
});

View File

@@ -8,11 +8,39 @@ import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
import { scheduleFullCrawl, scheduleIncrementalCrawl, getQueueStats, getQueue } from './queue.js';
import { syncAllSkillsToMeilisearch, checkMeilisearchHealth } from './meilisearch-sync.js';
import { createDb, skillQueries, categoryQueries, discoveredRepoQueries, awesomeListQueries, addRequestQueries, userQueries, sql } from '@skillhub/db';
import { createStrategyOrchestrator, createDeepScanCrawler, createAwesomeListCrawler } from './strategies/index.js';
import { createStrategyOrchestrator, createDeepScanCrawler, createAwesomeListCrawler, createPopularReposCrawler, createCommitsSearchCrawler } from './strategies/index.js';
import { createCrawler } from './crawler.js';
import { indexSkill } from './skill-indexer.js';
import { TokenManager } from './token-manager.js';
/**
* Parse a GitHub repository URL into owner, repo, and optional branch.
* Handles: github.com/owner/repo, github.com/owner/repo/tree/branch,
* github.com/owner/repo/tree/branch/some/path
*/
export function parseGitHubRepoUrl(urlStr: string): {
owner: string;
repo: string;
branch: string | null;
} {
const url = new URL(urlStr);
const parts = url.pathname.split('/').filter(Boolean);
const owner = parts[0];
const repo = parts[1];
if (!owner || !repo) {
throw new Error(`Invalid GitHub URL: ${urlStr}`);
}
// Check for /tree/{branch} path segment
if (parts[2] === 'tree' && parts[3]) {
return { owner, repo, branch: parts[3] };
}
return { owner, repo, branch: null };
}
const command = process.argv[2] || 'stats';
async function main() {
@@ -309,6 +337,91 @@ async function main() {
break;
}
case 'discover-popular': {
console.log('Discovering popular GitHub repositories...\n');
const popDb = createDb(process.env.DATABASE_URL);
const popCrawler = createPopularReposCrawler();
const popBudgetCrawler = createCrawler();
// Parse min-stars option (default 1000)
const minStarsArg = process.argv.find(a => a.startsWith('--min-stars='));
const minStars = minStarsArg ? parseInt(minStarsArg.split('=')[1]) : 1000;
// Check budget
const popBudget = await popBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${popBudget.remaining}/${popBudget.limit} remaining`);
if (!popBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await popBudgetCrawler.waitForBudget(0.20);
}
const repos = await popCrawler.discoverPopularRepos(minStars);
console.log(`\nSaving ${repos.length} popular repos to discovered_repos...`);
let popSaved = 0;
for (const repo of repos) {
try {
await discoveredRepoQueries.upsert(popDb, {
id: `${repo.owner}/${repo.repo}`,
owner: repo.owner,
repo: repo.repo,
discoveredVia: 'popular-scan',
githubStars: repo.stars,
githubForks: repo.forks,
defaultBranch: repo.defaultBranch,
isArchived: repo.isArchived,
});
popSaved++;
} catch {
// Skip errors
}
}
console.log(`Saved ${popSaved} repositories`);
console.log('Run "deep-scan" next to scan their branches for skills');
break;
}
case 'commits-search': {
console.log('Searching for repos with recent SKILL.md commits...\n');
const csDb = createDb(process.env.DATABASE_URL);
const csCrawler = createCommitsSearchCrawler();
const csBudgetCrawler = createCrawler();
// Parse days-back option (default 30)
const daysBackArg = process.argv.find(a => a.startsWith('--days='));
const daysBack = daysBackArg ? parseInt(daysBackArg.split('=')[1]) : 30;
// Check budget
const csBudget = await csBudgetCrawler.checkBudget(0.20);
console.log(`API Budget: ${csBudget.remaining}/${csBudget.limit} remaining`);
if (!csBudget.ok) {
console.log(`\nAPI budget too low. Waiting for reset...`);
await csBudgetCrawler.waitForBudget(0.20);
}
const csRepos = await csCrawler.discoverReposFromCommits(daysBack);
console.log(`\nSaving ${csRepos.length} repos to discovered_repos...`);
let csSaved = 0;
for (const repo of csRepos) {
try {
await discoveredRepoQueries.upsert(csDb, {
id: `${repo.owner}/${repo.repo}`,
owner: repo.owner,
repo: repo.repo,
discoveredVia: 'commits-search',
githubStars: repo.stars,
});
csSaved++;
} catch {
// Skip errors
}
}
console.log(`Saved ${csSaved} repositories`);
console.log('Run "deep-scan" next to scan their branches for skills');
break;
}
case 'deep-scan': {
console.log('Deep scanning discovered repositories...\n');
const deepDb = createDb(process.env.DATABASE_URL);
@@ -319,6 +432,22 @@ async function main() {
const deepBudgetArg = process.argv.find(a => a.startsWith('--budget='));
const deepBudgetPct = deepBudgetArg ? parseInt(deepBudgetArg.split('=')[1]) / 100 : 0.20;
// Parse branch scanning options
const allBranchesFlag = process.argv.includes('--all-branches');
const branchesArg = process.argv.find(a => a.startsWith('--branches='));
const extraBranchPatterns: string[] = branchesArg
? branchesArg.split('=')[1].split(',').map(p => p.trim()).filter(Boolean)
: [];
const deepScanOptions = { allBranches: allBranchesFlag, extraBranchPatterns };
if (allBranchesFlag) {
console.log('Branch mode: all branches');
} else if (extraBranchPatterns.length > 0) {
console.log(`Branch mode: smart + extra patterns [${extraBranchPatterns.join(', ')}]`);
} else {
console.log('Branch mode: smart (default + version/release branches)');
}
// Check initial budget
const deepInitialBudget = await skillCrawler.checkBudget(deepBudgetPct);
console.log(`API Budget: ${deepInitialBudget.remaining}/${deepInitialBudget.limit} remaining (reserve ${Math.round(deepBudgetPct * 100)}%)`);
@@ -355,7 +484,7 @@ async function main() {
try {
console.log(`\nScanning ${repo.owner}/${repo.repo}...`);
const skills = await deepCrawler.scanRepository(repo.owner, repo.repo);
const skills = await deepCrawler.scanRepository(repo.owner, repo.repo, deepScanOptions);
// Mark as scanned
await discoveredRepoQueries.markScanned(
@@ -646,19 +775,15 @@ async function main() {
}
try {
// Parse repository URL
const url = new URL(request.repositoryUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
const owner = pathParts[0];
const repo = pathParts[1];
// Parse repository URL (may include branch in /tree/{branch} path)
const { owner, repo, branch: urlBranch } = parseGitHubRepoUrl(request.repositoryUrl);
if (!owner || !repo) {
throw new Error('Invalid repository URL');
}
// Get default branch
// Get repo metadata for default branch
const repoMeta = await addCrawler.getRepoMetadata(owner, repo);
const branch = repoMeta.defaultBranch;
const branch = urlBranch ?? repoMeta.defaultBranch;
if (urlBranch) {
console.log(` Branch: ${branch} (from URL)`);
}
// Parse skill paths (comma-separated)
const skillPaths = request.skillPath.split(',').map((p: string) => p.trim());

View File

@@ -45,6 +45,7 @@ export interface RepoMetadata {
license: string | null;
description: string | null;
updatedAt: string;
createdAt: string;
defaultBranch: string;
topics: string[];
}
@@ -345,7 +346,7 @@ export class GitHubCrawler {
owner: item.repository.owner.login,
repo: item.repository.name,
path: skillPath,
branch: 'main',
branch: '', // empty → fetchSkillContent falls back to repoMeta.defaultBranch
sourceFormat: expectedFormat,
});
}
@@ -443,6 +444,7 @@ export class GitHubCrawler {
license: response.data.license?.spdx_id || null,
description: response.data.description,
updatedAt: response.data.updated_at,
createdAt: response.data.created_at,
defaultBranch: response.data.default_branch,
topics: response.data.topics || [],
};

View File

@@ -89,6 +89,7 @@ export async function indexSkill(
triggers: analysis.skill.metadata.triggers,
githubStars: content.repoMeta.stars,
githubForks: content.repoMeta.forks,
repoCreatedAt: new Date(content.repoMeta.createdAt),
securityScore: analysis.security.score,
securityStatus: analysis.security.status,
qualityScore: analysis.quality.overall,

View File

@@ -0,0 +1,146 @@
import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';
import { OctokitPool } from '../octokit-pool.js';
export interface CommitSearchRepoResult {
owner: string;
repo: string;
stars?: number;
}
/**
* Commits Search Discovery Strategy
* Searches GitHub for recent commits mentioning "SKILL.md" in their messages.
* This finds repos where someone recently added or modified a SKILL.md file,
* even on non-default branches (unlike Code Search which only indexes default branches).
*
* Limitation: Searches commit messages, not file paths. Only catches commits
* where the author mentioned "SKILL.md" in the message.
*/
export class CommitsSearchCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<{ octokit: Octokit; token: string }> {
return this.octokitPool.getBestInstance();
}
/**
* Search for recent commits mentioning SKILL.md in their messages.
* Returns unique repos found.
*/
async discoverReposFromCommits(daysBack: number = 30): Promise<CommitSearchRepoResult[]> {
const sinceDate = new Date();
sinceDate.setDate(sinceDate.getDate() - daysBack);
const dateStr = sinceDate.toISOString().split('T')[0]; // YYYY-MM-DD
const queries = [
`SKILL.md committer-date:>${dateStr}`,
`"add SKILL.md" committer-date:>${dateStr}`,
`"SKILL.md" path:skills committer-date:>${dateStr}`,
];
const allRepos = new Map<string, CommitSearchRepoResult>();
console.log(`Searching commits from last ${daysBack} days (since ${dateStr})...`);
for (const query of queries) {
try {
console.log(`\n Query: ${query}`);
const repos = await this.searchCommits(query);
for (const repo of repos) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (!allRepos.has(key)) {
allRepos.set(key, repo);
}
}
console.log(` Found ${repos.length} repos (total unique: ${allRepos.size})`);
} catch (error) {
console.warn(` Failed for query "${query}":`, error instanceof Error ? error.message : error);
}
}
console.log(`\nTotal unique repos from commits search: ${allRepos.size}`);
return Array.from(allRepos.values());
}
/**
* Execute a single commits search query and extract unique repos.
*/
private async searchCommits(query: string): Promise<CommitSearchRepoResult[]> {
const repos = new Map<string, CommitSearchRepoResult>();
const maxPages = 5;
const perPage = 100;
for (let page = 1; page <= maxPages; page++) {
try {
const { octokit, token } = await this.getOctokit();
const response = await octokit.search.commits({
q: query,
sort: 'committer-date',
order: 'desc',
per_page: perPage,
page,
});
this.octokitPool.updateStats(token, response.headers);
if (page === 1) {
console.log(` Total results: ${response.data.total_count}`);
}
for (const item of response.data.items) {
const repoData = item.repository;
const key = `${repoData.owner.login}/${repoData.name}`.toLowerCase();
if (!repos.has(key)) {
repos.set(key, {
owner: repoData.owner.login,
repo: repoData.name,
});
}
}
if (response.data.items.length < perPage) break;
} catch (error) {
if (this.isBeyondResultsLimit(error)) {
console.log(` Reached 1000-result limit`);
break;
}
if (this.isRateLimitError(error)) {
console.log(` Rate limit hit, rotating token...`);
await this.tokenManager.checkAndRotate();
page--; // Retry same page
continue;
}
throw error;
}
}
return Array.from(repos.values());
}
private isRateLimitError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const status = (error as { status?: number }).status;
return status === 403 || status === 429;
}
private isBeyondResultsLimit(error: unknown): boolean {
return (
error instanceof Error &&
'status' in error &&
(error as { status: number }).status === 422
);
}
}
export function createCommitsSearchCrawler(tokenManager?: TokenManager | string): CommitsSearchCrawler {
if (typeof tokenManager === 'string') {
return new CommitsSearchCrawler(new TokenManager([tokenManager]));
}
return new CommitsSearchCrawler(tokenManager);
}

View File

@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { filterAndSortBranches } from './deep-scan.js';
describe('filterAndSortBranches', () => {
it('returns only defaultBranch when no matching branches exist', () => {
const result = filterAndSortBranches(
['main', 'feature/foo', 'bugfix/bar', 'experiment'],
'main'
);
expect(result).toEqual(['main']);
});
it('includes well-known branch names (up to 5 non-default cap)', () => {
const result = filterAndSortBranches(
['main', 'stable', 'next', 'latest', 'canary', 'dev', 'develop', 'feature/x'],
'main'
);
expect(result[0]).toBe('main');
expect(result).toContain('stable');
expect(result).toContain('next');
expect(result).toContain('dev');
// 6 well-known names but only 5 non-default slots, so some get dropped
expect(result.length).toBe(6); // main + 5
expect(result).not.toContain('feature/x');
});
it('includes version branches matching /^[vV]\\d/', () => {
const result = filterAndSortBranches(
['main', 'v4', 'v3', 'v2', 'v1'],
'main'
);
expect(result[0]).toBe('main');
expect(result).toContain('v4');
expect(result).toContain('v3');
expect(result).toContain('v2');
expect(result).toContain('v1');
});
it('sorts version branches by semver descending', () => {
const result = filterAndSortBranches(
['main', 'v1', 'v3', 'v2', 'v10', 'v4'],
'main'
);
const versions = result.filter(b => b.startsWith('v'));
expect(versions[0]).toBe('v10');
expect(versions[1]).toBe('v4');
expect(versions[2]).toBe('v3');
expect(versions[3]).toBe('v2');
});
it('handles complex version numbers (v2.1, v3.0.1)', () => {
const result = filterAndSortBranches(
['main', 'v2.1', 'v2.0', 'v3.0.1', 'v1.5'],
'main'
);
const versions = result.filter(b => b.startsWith('v'));
expect(versions[0]).toBe('v3.0.1');
expect(versions[1]).toBe('v2.1');
expect(versions[2]).toBe('v2.0');
expect(versions[3]).toBe('v1.5');
});
it('takes only top 5 version branches', () => {
const result = filterAndSortBranches(
['main', 'v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8'],
'main'
);
// defaultBranch + 5 non-default max
expect(result.length).toBeLessThanOrEqual(6);
// Should have the top 5 versions (v8, v7, v6, v5, v4)
expect(result).toContain('v8');
expect(result).toContain('v7');
expect(result).toContain('v6');
expect(result).toContain('v5');
expect(result).toContain('v4');
expect(result).not.toContain('v1');
});
it('caps total non-default at 5 when mixing well-known + versions', () => {
const result = filterAndSortBranches(
['main', 'stable', 'next', 'dev', 'v4', 'v3', 'v2', 'v1'],
'main'
);
// 1 default + max 5 non-default
expect(result.length).toBeLessThanOrEqual(6);
expect(result[0]).toBe('main');
});
it('includes release/ and releases/ prefixed branches', () => {
const result = filterAndSortBranches(
['main', 'release/3.0', 'releases/v2', 'feature/x'],
'main'
);
expect(result).toContain('release/3.0');
expect(result).toContain('releases/v2');
expect(result).not.toContain('feature/x');
});
it('applies extra patterns from CLI (exact and prefix match)', () => {
const result = filterAndSortBranches(
['main', 'alpha', 'beta', 'alpha/test', 'feature/x'],
'main',
['alpha', 'beta']
);
expect(result).toContain('alpha');
expect(result).toContain('beta');
expect(result).not.toContain('feature/x');
});
it('excludes defaultBranch from filtered list even if it matches a pattern', () => {
const result = filterAndSortBranches(
['dev', 'v4', 'stable'],
'dev'
);
expect(result[0]).toBe('dev');
// dev should appear only once
const devCount = result.filter(b => b === 'dev').length;
expect(devCount).toBe(1);
});
it('puts defaultBranch first always', () => {
const result = filterAndSortBranches(
['v10', 'master', 'v5', 'stable'],
'master'
);
expect(result[0]).toBe('master');
});
it('handles V (uppercase) version branches', () => {
const result = filterAndSortBranches(
['main', 'V4', 'V3'],
'main'
);
expect(result).toContain('V4');
expect(result).toContain('V3');
});
it('returns [defaultBranch] for empty branch list', () => {
const result = filterAndSortBranches([], 'main');
expect(result).toEqual(['main']);
});
it('returns [defaultBranch] when only default is in the list', () => {
const result = filterAndSortBranches(['main'], 'main');
expect(result).toEqual(['main']);
});
});

View File

@@ -3,10 +3,92 @@ import { TokenManager } from '../token-manager.js';import { OctokitPool } from '
import type { SkillSource } from 'skillhub-core';
import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core';
export interface DeepScanOptions {
allBranches?: boolean;
extraBranchPatterns?: string[];
}
/** Well-known branch names that indicate important non-default branches. */
const IMPORTANT_BRANCH_NAMES = ['stable', 'next', 'latest', 'canary', 'dev', 'develop'];
/** Prefixes that indicate release/version branches. */
const IMPORTANT_BRANCH_PREFIXES = ['release/', 'releases/'];
/**
* Pure helper: filter and sort branches to find important ones worth scanning.
*
* Rules:
* 1. Always include defaultBranch first
* 2. Include exact name matches from IMPORTANT_BRANCH_NAMES
* 3. Include prefix matches from IMPORTANT_BRANCH_PREFIXES
* 4. Include version branches matching /^[vV]\d/ — sorted by semver desc, top 5
* 5. Include branches matching extraPatterns (exact or prefix match)
* 6. Cap non-default branches at 5
*/
export function filterAndSortBranches(
allBranchNames: string[],
defaultBranch: string,
extraPatterns: string[] = []
): string[] {
const versionBranches: string[] = [];
const otherImportant: string[] = [];
for (const name of allBranchNames) {
if (name === defaultBranch) continue;
// Check well-known exact names
if (IMPORTANT_BRANCH_NAMES.includes(name)) {
otherImportant.push(name);
continue;
}
// Check prefix patterns (release/, releases/)
const lower = name.toLowerCase();
if (IMPORTANT_BRANCH_PREFIXES.some(p => lower.startsWith(p))) {
otherImportant.push(name);
continue;
}
// Version branches: v* or V* followed by a digit (e.g., v4, v2.1, V3)
if (/^[vV]\d/.test(name)) {
versionBranches.push(name);
continue;
}
// Check extra patterns from CLI (exact or prefix match)
if (extraPatterns.length > 0) {
const matched = extraPatterns.some(p => name === p || name.startsWith(p + '/'));
if (matched) {
otherImportant.push(name);
}
}
}
// Sort version branches by semver descending, take top 5
const sortedVersionBranches = versionBranches
.sort((a, b) => {
const normalize = (s: string) =>
s.replace(/^[vV]/, '').split(/[.\-x]/).map(n => parseInt(n) || 0);
const aN = normalize(a);
const bN = normalize(b);
for (let i = 0; i < Math.max(aN.length, bN.length); i++) {
const diff = (bN[i] || 0) - (aN[i] || 0);
if (diff !== 0) return diff;
}
return 0;
})
.slice(0, 5);
// Combine: other important first, then version branches, cap at 5 non-default total
const nonDefault = [...otherImportant, ...sortedVersionBranches].slice(0, 5);
return [defaultBranch, ...nonDefault];
}
/**
* Deep Scan Strategy
* Uses Git Trees API to recursively scan entire repositories for SKILL.md files
* Can discover skills that aren't found by code search
* Supports scanning multiple branches per repository
*/
export class DeepScanCrawler {
private octokitPool: OctokitPool;
@@ -22,35 +104,98 @@ export class DeepScanCrawler {
}
/**
* Deep scan a repository for all SKILL.md files using Git Trees API
* This is more thorough than code search as it scans the entire repo
* List important branches worth scanning beyond the default branch.
* Calls octokit.repos.listBranches (1 API call).
*/
async scanRepository(owner: string, repo: string): Promise<SkillSource[]> {
private async listImportantBranches(
owner: string,
repo: string,
defaultBranch: string,
extraPatterns: string[] = []
): Promise<string[]> {
try {
const { octokit, token } = await this.getOctokit();
const response = await octokit.repos.listBranches({
owner,
repo,
per_page: 100,
});
this.octokitPool.updateStats(token, response.headers);
const allBranchNames = response.data.map(b => b.name);
return filterAndSortBranches(allBranchNames, defaultBranch, extraPatterns);
} catch {
console.log(` Could not list branches for ${owner}/${repo}, using default only`);
return [defaultBranch];
}
}
/**
* List all branches in a repository (for --all-branches mode).
* Handles pagination for repos with many branches.
*/
private async listAllBranches(
owner: string,
repo: string,
defaultBranch: string
): Promise<string[]> {
const allBranches: string[] = [defaultBranch];
let page = 1;
for (;;) {
try {
const { octokit, token } = await this.getOctokit();
const response = await octokit.repos.listBranches({
owner,
repo,
per_page: 100,
page,
});
this.octokitPool.updateStats(token, response.headers);
for (const branch of response.data) {
if (branch.name !== defaultBranch) {
allBranches.push(branch.name);
}
}
if (response.data.length < 100) break;
page++;
} catch {
break;
}
}
console.log(` Found ${allBranches.length} total branches for ${owner}/${repo}`);
return allBranches;
}
/**
* Scan a single branch for instruction files using the Git Trees API.
* Returns [] silently for truncated trees (fallback handles those at repo level).
*/
private async scanSingleBranch(
owner: string,
repo: string,
branch: string
): Promise<SkillSource[]> {
const skills: SkillSource[] = [];
try {
// Get repository info for default branch
const { octokit, token } = await this.getOctokit();
const repoInfo = await octokit.repos.get({ owner, repo });
this.octokitPool.updateStats(token, repoInfo.headers);
if (repoInfo.data.archived) {
console.log(` Skipping archived repo: ${owner}/${repo}`);
return [];
}
const defaultBranch = repoInfo.data.default_branch;
// Get the full tree recursively
const tree = await octokit.git.getTree({
owner,
repo,
tree_sha: defaultBranch,
tree_sha: branch,
recursive: 'true',
});
this.octokitPool.updateStats(token, tree.headers);
// Find all instruction files (SKILL.md, .cursorrules, .windsurfrules, AGENTS.md, etc.)
if (tree.data.truncated) {
// Truncated — skip this branch silently
return [];
}
const instructionFiles = tree.data.tree.filter(
(item) => {
if (item.type !== 'blob' || !item.path) return false;
@@ -78,12 +223,80 @@ export class DeepScanCrawler {
owner,
repo,
path: skillPath,
branch: defaultBranch,
branch,
sourceFormat: matchedPattern.format,
});
}
} catch (error) {
if (this.isNotFoundError(error)) return [];
// Rate limit and other errors propagate to scanRepository()
throw error;
}
return skills;
}
/**
* Deep scan a repository for all instruction files using Git Trees API.
* Scans multiple important branches (version, release, well-known names).
* Deduplicates skills across branches, preferring the default branch.
*/
async scanRepository(owner: string, repo: string, options: DeepScanOptions = {}): Promise<SkillSource[]> {
try {
const { octokit, token } = await this.getOctokit();
const repoInfo = await octokit.repos.get({ owner, repo });
this.octokitPool.updateStats(token, repoInfo.headers);
if (repoInfo.data.archived) {
console.log(` Skipping archived repo: ${owner}/${repo}`);
return [];
}
const defaultBranch = repoInfo.data.default_branch;
// Determine which branches to scan
let branchesToScan: string[];
if (options.allBranches) {
branchesToScan = await this.listAllBranches(owner, repo, defaultBranch);
} else {
branchesToScan = await this.listImportantBranches(
owner, repo, defaultBranch, options.extraBranchPatterns
);
}
if (branchesToScan.length > 1) {
console.log(` Scanning ${branchesToScan.length} branches: ${branchesToScan.join(', ')}`);
}
// Collect skills from each branch, deduplicating by path::format
const skillsByKey = new Map<string, SkillSource>();
for (const branch of branchesToScan) {
const branchSkills = await this.scanSingleBranch(owner, repo, branch);
for (const skill of branchSkills) {
const key = `${skill.path}::${skill.sourceFormat || 'skill.md'}`;
const existing = skillsByKey.get(key);
if (!existing) {
skillsByKey.set(key, skill);
} else if (existing.branch !== defaultBranch && skill.branch === defaultBranch) {
// Prefer default branch version
skillsByKey.set(key, skill);
}
// Otherwise keep existing (first non-default wins if default doesn't have it)
}
}
const skills = Array.from(skillsByKey.values());
if (skills.length > 0) {
console.log(` Found ${skills.length} skills in ${owner}/${repo}`);
const nonDefault = skills.filter(s => s.branch !== defaultBranch);
if (nonDefault.length > 0) {
console.log(` Found ${skills.length} skills in ${owner}/${repo} (${nonDefault.length} on non-default branches)`);
} else {
console.log(` Found ${skills.length} skills in ${owner}/${repo}`);
}
}
return skills;
@@ -92,15 +305,13 @@ export class DeepScanCrawler {
return [];
}
if (this.isTruncatedError(error)) {
// Tree is truncated (>100k files), fall back to directory listing
console.log(` Repository ${owner}/${repo} is too large, using fallback scan`);
return this.fallbackScan(owner, repo);
return this.fallbackScanWithDefaultBranch(owner, repo);
}
if (this.isRateLimitError(error)) {
console.log(` Rate limit hit scanning ${owner}/${repo}, waiting for token rotation...`);
await this.tokenManager.checkAndRotate();
// Retry once after rotation
return this.scanRepository(owner, repo);
return this.scanRepository(owner, repo, options);
}
throw error;
}
@@ -114,9 +325,27 @@ export class DeepScanCrawler {
}
/**
* Fallback scan for large repositories - scan known skill directories
* Wrapper: fetches defaultBranch then runs fallback scan.
* Used when getTree is truncated for repos with >100k files.
*/
private async fallbackScan(owner: string, repo: string): Promise<SkillSource[]> {
private async fallbackScanWithDefaultBranch(owner: string, repo: string): Promise<SkillSource[]> {
let defaultBranch = 'main';
try {
const { octokit, token } = await this.getOctokit();
const repoInfo = await octokit.repos.get({ owner, repo });
this.octokitPool.updateStats(token, repoInfo.headers);
defaultBranch = repoInfo.data.default_branch;
} catch {
// Use 'main' as safe fallback
}
return this.fallbackScan(owner, repo, defaultBranch);
}
/**
* Fallback scan for large repositories - scan known skill directories.
* Uses the actual defaultBranch instead of hardcoding 'main'.
*/
private async fallbackScan(owner: string, repo: string, defaultBranch: string): Promise<SkillSource[]> {
const skills: SkillSource[] = [];
const knownPaths = ['skills', '.claude/skills', '.github/skills', '.codex/skills', ''];
@@ -128,6 +357,7 @@ export class DeepScanCrawler {
owner,
repo,
path: basePath || '.',
ref: defaultBranch,
});
this.octokitPool.updateStats(token, response.headers);
@@ -141,7 +371,7 @@ export class DeepScanCrawler {
owner,
repo,
path: basePath || '.',
branch: 'main',
branch: defaultBranch,
sourceFormat: 'skill.md' as SourceFormat,
});
}
@@ -155,6 +385,7 @@ export class DeepScanCrawler {
owner,
repo,
path: dir.path,
ref: defaultBranch,
});
this.octokitPool.updateStats(subToken, subDir.headers);
@@ -165,7 +396,7 @@ export class DeepScanCrawler {
owner,
repo,
path: dir.path,
branch: 'main',
branch: defaultBranch,
sourceFormat: 'skill.md' as SourceFormat,
});
}
@@ -190,7 +421,7 @@ export class DeepScanCrawler {
owner,
repo,
path: '.',
branch: 'main',
branch: defaultBranch,
sourceFormat: pattern.format,
});
}
@@ -206,14 +437,15 @@ export class DeepScanCrawler {
* Scan multiple repositories
*/
async scanRepositories(
repos: Array<{ owner: string; repo: string }>
repos: Array<{ owner: string; repo: string }>,
options: DeepScanOptions = {}
): Promise<Map<string, SkillSource[]>> {
const results = new Map<string, SkillSource[]>();
for (const { owner, repo } of repos) {
try {
console.log(`Deep scanning: ${owner}/${repo}`);
const skills = await this.scanRepository(owner, repo);
const skills = await this.scanRepository(owner, repo, options);
results.set(`${owner}/${repo}`, skills);
} catch (error) {
console.warn(` Failed to scan ${owner}/${repo}:`, error);

View File

@@ -7,6 +7,7 @@ import { createAwesomeListCrawler } from './awesome-list.js';
import { createTopicSearchCrawler } from './topic-search.js';
import { createDeepScanCrawler } from './deep-scan.js';
import { createForkNetworkCrawler } from './fork-network.js';
import { createCommitsSearchCrawler } from './commits-search.js';
import { TokenManager } from '../token-manager.js';
import type { SkillSource } from 'skillhub-core';
@@ -41,6 +42,7 @@ export class StrategyOrchestrator {
private topicCrawler: ReturnType<typeof createTopicSearchCrawler>;
private deepScanCrawler: ReturnType<typeof createDeepScanCrawler>;
private forkCrawler: ReturnType<typeof createForkNetworkCrawler>;
private commitsCrawler: ReturnType<typeof createCommitsSearchCrawler>;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
@@ -49,6 +51,7 @@ export class StrategyOrchestrator {
this.topicCrawler = createTopicSearchCrawler(this.tokenManager);
this.deepScanCrawler = createDeepScanCrawler(this.tokenManager);
this.forkCrawler = createForkNetworkCrawler(this.tokenManager);
this.commitsCrawler = createCommitsSearchCrawler(this.tokenManager);
}
/**
@@ -169,6 +172,32 @@ export class StrategyOrchestrator {
};
}
/**
* Run commits search discovery (finds repos with recent SKILL.md commits)
*/
async runCommitsSearchStrategy(): Promise<DiscoveryResult> {
console.log('\n=== Running Commits Search Strategy ===');
const startTime = Date.now();
const repoResults = await this.commitsCrawler.discoverReposFromCommits(30);
const repos = repoResults.map((r) => ({
owner: r.owner,
repo: r.repo,
stars: r.stars,
discoveredVia: 'commits-search',
}));
console.log(`Commits search strategy completed in ${Date.now() - startTime}ms`);
console.log(`Discovered ${repos.length} repositories`);
return {
source: 'commits-search',
repos,
skills: [],
};
}
/**
* Run all strategies and return combined results
*/
@@ -230,7 +259,24 @@ export class StrategyOrchestrator {
console.error('Topic search strategy failed:', error);
}
// 3. Fork network (for high-star repos from above)
// 3. Commits search (find repos with recent SKILL.md commits)
try {
const commitsResult = await this.runCommitsSearchStrategy();
stats.byStrategy['commits-search'] = {
repos: commitsResult.repos.length,
skills: 0,
};
for (const repo of commitsResult.repos) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (!allRepos.has(key)) {
allRepos.set(key, repo);
}
}
} catch (error) {
console.error('Commits search strategy failed:', error);
}
// 4. Fork network (for high-star repos from above)
try {
const highStarRepos = Array.from(allRepos.values())
.filter((r) => (r.stars || 0) >= 10)
@@ -283,10 +329,15 @@ export function createStrategyOrchestrator(tokenManager?: TokenManager | string)
// Re-export strategy creators
export { createAwesomeListCrawler } from './awesome-list.js';
export { createTopicSearchCrawler } from './topic-search.js';
export { createDeepScanCrawler } from './deep-scan.js';
export { createDeepScanCrawler, filterAndSortBranches } from './deep-scan.js';
export type { DeepScanOptions } from './deep-scan.js';
export { createForkNetworkCrawler } from './fork-network.js';
export { createPopularReposCrawler } from './popular-repos.js';
export { createCommitsSearchCrawler } from './commits-search.js';
// Re-export types
export type { RepoReference } from './awesome-list.js';
export type { RepoResult } from './topic-search.js';
export type { ForkInfo } from './fork-network.js';
export type { PopularRepoResult } from './popular-repos.js';
export type { CommitSearchRepoResult } from './commits-search.js';

View File

@@ -0,0 +1,163 @@
import type { Octokit } from '@octokit/rest';
import { TokenManager } from '../token-manager.js';
import { OctokitPool } from '../octokit-pool.js';
export interface PopularRepoResult {
owner: string;
repo: string;
stars: number;
forks: number;
defaultBranch: string;
isArchived: boolean;
}
/**
* Popular Repos Discovery Strategy
* Discovers popular GitHub repos (by stars) and adds them to discovered_repos
* so deep-scan can check their branches for SKILL.md files.
*
* This catches repos that add SKILL.md on non-default branches
* which GitHub Code Search doesn't index.
*/
export class PopularReposCrawler {
private octokitPool: OctokitPool;
private tokenManager: TokenManager;
constructor(tokenManager?: TokenManager) {
this.tokenManager = tokenManager || TokenManager.getInstance();
this.octokitPool = new OctokitPool(this.tokenManager);
}
private async getOctokit(): Promise<{ octokit: Octokit; token: string }> {
return this.octokitPool.getBestInstance();
}
/**
* Discover popular repos by star count ranges.
* Segments by star ranges to bypass GitHub's 1000-result-per-query limit.
*/
async discoverPopularRepos(minStars: number = 1000): Promise<PopularRepoResult[]> {
const starRanges = this.buildStarRanges(minStars);
const allRepos = new Map<string, PopularRepoResult>();
console.log(`Discovering popular repos (${minStars}+ stars) across ${starRanges.length} ranges...`);
for (const range of starRanges) {
try {
console.log(`\n Searching: stars:${range}`);
const repos = await this.searchByStarRange(range);
for (const repo of repos) {
const key = `${repo.owner}/${repo.repo}`.toLowerCase();
if (!allRepos.has(key)) {
allRepos.set(key, repo);
}
}
console.log(` Found ${repos.length} repos (total unique: ${allRepos.size})`);
} catch (error) {
console.warn(` Failed for range stars:${range}:`, error instanceof Error ? error.message : error);
}
}
console.log(`\nTotal unique repos discovered: ${allRepos.size}`);
return Array.from(allRepos.values());
}
/**
* Build star ranges to segment search queries.
* GitHub search returns max 1000 results per query.
*/
private buildStarRanges(minStars: number): string[] {
const ranges: string[] = [];
const breakpoints = [500, 1000, 2000, 5000, 10000, 25000, 50000, 100000];
// Filter breakpoints based on minStars
const relevantBreaks = breakpoints.filter(b => b >= minStars);
for (let i = 0; i < relevantBreaks.length; i++) {
const low = i === 0 ? minStars : relevantBreaks[i - 1];
const high = relevantBreaks[i];
if (low < high) {
ranges.push(`${low}..${high}`);
}
}
// Add the final "greater than" range
const lastBreak = relevantBreaks[relevantBreaks.length - 1] || minStars;
ranges.push(`>${lastBreak}`);
return ranges;
}
/**
* Search repos within a specific star range.
*/
private async searchByStarRange(starRange: string): Promise<PopularRepoResult[]> {
const results: PopularRepoResult[] = [];
const maxPages = 10;
const perPage = 100;
for (let page = 1; page <= maxPages; page++) {
try {
const { octokit, token } = await this.getOctokit();
const response = await octokit.search.repos({
q: `stars:${starRange}`,
sort: 'stars',
order: 'desc',
per_page: perPage,
page,
});
this.octokitPool.updateStats(token, response.headers);
for (const repo of response.data.items) {
if (!repo.archived) {
results.push({
owner: repo.owner.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,
defaultBranch: repo.default_branch,
isArchived: repo.archived,
});
}
}
if (response.data.items.length < perPage) break;
} catch (error) {
if (this.isBeyondResultsLimit(error)) {
console.log(` Reached 1000-result limit for stars:${starRange}`);
break;
}
if (this.isRateLimitError(error)) {
console.log(` Rate limit hit, rotating token...`);
await this.tokenManager.checkAndRotate();
page--; // Retry same page
continue;
}
throw error;
}
}
return results;
}
private isRateLimitError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const status = (error as { status?: number }).status;
return status === 403 || status === 429;
}
private isBeyondResultsLimit(error: unknown): boolean {
return (
error instanceof Error &&
'status' in error &&
(error as { status: number }).status === 422
);
}
}
export function createPopularReposCrawler(tokenManager?: TokenManager | string): PopularReposCrawler {
if (typeof tokenManager === 'string') {
return new PopularReposCrawler(new TokenManager([tokenManager]));
}
return new PopularReposCrawler(tokenManager);
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});