From b05cb21a652e23d738bbefb5ae96b0dc2b125679 Mon Sep 17 00:00:00 2001 From: airano Date: Sun, 22 Feb 2026 04:16:35 +0330 Subject: [PATCH] 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 --- README.md | 1 + apps/web/sentry.client.config.ts | 17 +- packages/core/src/skill-parser.ts | 23 +- packages/db/src/curation.test.ts | 256 +++++++++++++++ packages/db/src/queries.ts | 39 ++- packages/db/src/schema.ts | 1 + packages/db/src/test-utils.ts | 1 + pnpm-lock.yaml | 3 + scripts/init-db.sql | 8 + services/indexer/package.json | 6 +- services/indexer/src/crawl.test.ts | 55 ++++ services/indexer/src/crawl.ts | 151 ++++++++- services/indexer/src/crawler.ts | 4 +- services/indexer/src/skill-indexer.ts | 1 + .../indexer/src/strategies/commits-search.ts | 146 +++++++++ .../indexer/src/strategies/deep-scan.test.ts | 147 +++++++++ services/indexer/src/strategies/deep-scan.ts | 292 ++++++++++++++++-- services/indexer/src/strategies/index.ts | 55 +++- .../indexer/src/strategies/popular-repos.ts | 163 ++++++++++ services/indexer/vitest.config.ts | 9 + 20 files changed, 1314 insertions(+), 64 deletions(-) create mode 100644 packages/db/src/curation.test.ts create mode 100644 services/indexer/src/crawl.test.ts create mode 100644 services/indexer/src/strategies/commits-search.ts create mode 100644 services/indexer/src/strategies/deep-scan.test.ts create mode 100644 services/indexer/src/strategies/popular-repos.ts create mode 100644 services/indexer/vitest.config.ts diff --git a/README.md b/README.md index 6976191..c5dfccd 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ Always review skill source code before installing. --- + ## License MIT - See [LICENSE](./LICENSE) for details. diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts index fbc2b25..b31edb3 100644 --- a/apps/web/sentry.client.config.ts +++ b/apps/web/sentry.client.config.ts @@ -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); diff --git a/packages/core/src/skill-parser.ts b/packages/core/src/skill-parser.ts index 732f57a..4a48f25 100644 --- a/packages/core/src/skill-parser.ts +++ b/packages/core/src/skill-parser.ts @@ -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, diff --git a/packages/db/src/curation.test.ts b/packages/db/src/curation.test.ts new file mode 100644 index 0000000..e2e5ee7 --- /dev/null +++ b/packages/db/src/curation.test.ts @@ -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); + }); + }); +}); diff --git a/packages/db/src/queries.ts b/packages/db/src/queries.ts index 2ea9eea..4a6ee2c 100644 --- a/packages/db/src/queries.ts +++ b/packages/db/src/queries.ts @@ -37,6 +37,9 @@ type DB = PostgresJsDatabase; */ 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) + ) + ) ) ) ) diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b42a98e..8f097cc 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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'), }, diff --git a/packages/db/src/test-utils.ts b/packages/db/src/test-utils.ts index b7fc6e8..e8324ba 100644 --- a/packages/db/src/test-utils.ts +++ b/packages/db/src/test-utils.ts @@ -46,6 +46,7 @@ export function createTestSkill(overrides: Partial = {}): SkillInse contentHash: 'abc123', createdAt: new Date(), updatedAt: new Date(), + repoCreatedAt: null, indexedAt: new Date(), ...overrides, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51bf1c6..95c87f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 6ac8821..91af8b9 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -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; diff --git a/services/indexer/package.json b/services/indexer/package.json index f60870b..651b1bc 100644 --- a/services/indexer/package.json +++ b/services/indexer/package.json @@ -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" } } diff --git a/services/indexer/src/crawl.test.ts b/services/indexer/src/crawl.test.ts new file mode 100644 index 0000000..ff87fb1 --- /dev/null +++ b/services/indexer/src/crawl.test.ts @@ -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 }); + }); +}); diff --git a/services/indexer/src/crawl.ts b/services/indexer/src/crawl.ts index 31133cf..b611576 100644 --- a/services/indexer/src/crawl.ts +++ b/services/indexer/src/crawl.ts @@ -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()); diff --git a/services/indexer/src/crawler.ts b/services/indexer/src/crawler.ts index a8240ae..ac70d25 100644 --- a/services/indexer/src/crawler.ts +++ b/services/indexer/src/crawler.ts @@ -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 || [], }; diff --git a/services/indexer/src/skill-indexer.ts b/services/indexer/src/skill-indexer.ts index 2cdb545..c9e3338 100644 --- a/services/indexer/src/skill-indexer.ts +++ b/services/indexer/src/skill-indexer.ts @@ -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, diff --git a/services/indexer/src/strategies/commits-search.ts b/services/indexer/src/strategies/commits-search.ts new file mode 100644 index 0000000..b14f182 --- /dev/null +++ b/services/indexer/src/strategies/commits-search.ts @@ -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 { + 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(); + + 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 { + const repos = new Map(); + 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); +} diff --git a/services/indexer/src/strategies/deep-scan.test.ts b/services/indexer/src/strategies/deep-scan.test.ts new file mode 100644 index 0000000..f9e5e9d --- /dev/null +++ b/services/indexer/src/strategies/deep-scan.test.ts @@ -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']); + }); +}); diff --git a/services/indexer/src/strategies/deep-scan.ts b/services/indexer/src/strategies/deep-scan.ts index da2b6c5..0751cc3 100644 --- a/services/indexer/src/strategies/deep-scan.ts +++ b/services/indexer/src/strategies/deep-scan.ts @@ -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 { + private async listImportantBranches( + owner: string, + repo: string, + defaultBranch: string, + extraPatterns: string[] = [] + ): Promise { + 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 { + 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 { 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 { + 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(); + + 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 { + private async fallbackScanWithDefaultBranch(owner: string, repo: string): Promise { + 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 { 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> { const results = new Map(); 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); diff --git a/services/indexer/src/strategies/index.ts b/services/indexer/src/strategies/index.ts index bafbc4d..97d74c5 100644 --- a/services/indexer/src/strategies/index.ts +++ b/services/indexer/src/strategies/index.ts @@ -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; private deepScanCrawler: ReturnType; private forkCrawler: ReturnType; + private commitsCrawler: ReturnType; 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 { + 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'; diff --git a/services/indexer/src/strategies/popular-repos.ts b/services/indexer/src/strategies/popular-repos.ts new file mode 100644 index 0000000..4e28dae --- /dev/null +++ b/services/indexer/src/strategies/popular-repos.ts @@ -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 { + const starRanges = this.buildStarRanges(minStars); + const allRepos = new Map(); + + 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 { + 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); +} diff --git a/services/indexer/vitest.config.ts b/services/indexer/vitest.config.ts new file mode 100644 index 0000000..7dd1325 --- /dev/null +++ b/services/indexer/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});