From 9187b8edbc5b19c8e604bf9e6289263d9a9b22f7 Mon Sep 17 00:00:00 2001 From: Rohit Ganguly Date: Thu, 21 May 2026 18:59:27 +0530 Subject: [PATCH 1/8] chore: configure frontend build arguments and update postgres host port to 5433 --- apps/frontend/Dockerfile | 4 ++++ docker-compose.yml | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 51bfe6d..a676021 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -6,6 +6,10 @@ COPY package*.json ./ RUN npm ci --legacy-peer-deps COPY . . +ARG BACKEND_URL +ARG NEXT_PUBLIC_BACKEND_URL +ENV BACKEND_URL=${BACKEND_URL} +ENV NEXT_PUBLIC_BACKEND_URL=${NEXT_PUBLIC_BACKEND_URL} ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build diff --git a/docker-compose.yml b/docker-compose.yml index 684cdf9..7ed6615 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_USER: devpulse POSTGRES_PASSWORD: devpulse ports: - - "5432:5432" + - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data @@ -38,7 +38,11 @@ services: - "5001:5001" frontend: - build: ./apps/frontend + build: + context: ./apps/frontend + args: + - BACKEND_URL=http://backend:3000 + - NEXT_PUBLIC_BACKEND_URL=http://localhost:3000 ports: - "3001:3001" depends_on: From 4a5ed13ab992a56cec6f608d1cce4e75666f7f9d Mon Sep 17 00:00:00 2001 From: Rohit Ganguly Date: Thu, 21 May 2026 19:30:23 +0530 Subject: [PATCH 2/8] feat: implement OS Finder service, API endpoints, and frontend discovery dashboards to analyze and track repository health metrics. --- apps/backend/Dockerfile | 9 +- apps/backend/src/app.module.ts | 2 + .../202605210001-create-os-finder-tables.ts | 65 ++ .../src/os-finder/ai-query-builder.service.ts | 215 +++++ .../src/os-finder/dto/save-repo.dto.ts | 72 ++ .../src/os-finder/dto/search-query.dto.ts | 115 +++ .../os-finder/dto/update-saved-repo.dto.ts | 12 + .../entities/os-finder-search.entity.ts | 42 + .../os-finder/entities/saved-repo.entity.ts | 80 ++ .../os-finder/github-query-builder.spec.ts | 137 ++++ .../src/os-finder/github-query-builder.ts | 110 +++ .../src/os-finder/ncf-scorer.service.spec.ts | 163 ++++ .../src/os-finder/ncf-scorer.service.ts | 229 ++++++ .../src/os-finder/os-finder-cache.service.ts | 96 +++ .../src/os-finder/os-finder.controller.ts | 109 +++ .../backend/src/os-finder/os-finder.module.ts | 39 + .../src/os-finder/os-finder.service.ts | 711 ++++++++++++++++ .../src/os-finder/repo-health.service.ts | 181 ++++ apps/frontend/Dockerfile | 5 +- .../components/dashboard/DashboardShell.tsx | 3 +- apps/frontend/next.config.js | 4 + .../dashboard/os-finder/[owner]/[repo].tsx | 644 +++++++++++++++ .../pages/dashboard/os-finder/index.tsx | 772 ++++++++++++++++++ .../pages/dashboard/os-finder/saved.tsx | 317 +++++++ apps/frontend/tsconfig.tsbuildinfo | 2 +- docker-compose.yml | 7 +- packages/shared-types/os-finder.types.ts | 91 +++ 27 files changed, 4222 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/database/migrations/202605210001-create-os-finder-tables.ts create mode 100644 apps/backend/src/os-finder/ai-query-builder.service.ts create mode 100644 apps/backend/src/os-finder/dto/save-repo.dto.ts create mode 100644 apps/backend/src/os-finder/dto/search-query.dto.ts create mode 100644 apps/backend/src/os-finder/dto/update-saved-repo.dto.ts create mode 100644 apps/backend/src/os-finder/entities/os-finder-search.entity.ts create mode 100644 apps/backend/src/os-finder/entities/saved-repo.entity.ts create mode 100644 apps/backend/src/os-finder/github-query-builder.spec.ts create mode 100644 apps/backend/src/os-finder/github-query-builder.ts create mode 100644 apps/backend/src/os-finder/ncf-scorer.service.spec.ts create mode 100644 apps/backend/src/os-finder/ncf-scorer.service.ts create mode 100644 apps/backend/src/os-finder/os-finder-cache.service.ts create mode 100644 apps/backend/src/os-finder/os-finder.controller.ts create mode 100644 apps/backend/src/os-finder/os-finder.module.ts create mode 100644 apps/backend/src/os-finder/os-finder.service.ts create mode 100644 apps/backend/src/os-finder/repo-health.service.ts create mode 100644 apps/frontend/pages/dashboard/os-finder/[owner]/[repo].tsx create mode 100644 apps/frontend/pages/dashboard/os-finder/index.tsx create mode 100644 apps/frontend/pages/dashboard/os-finder/saved.tsx create mode 100644 packages/shared-types/os-finder.types.ts diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index bdf7558..0202296 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -2,10 +2,11 @@ FROM node:20-alpine AS builder WORKDIR /app -COPY package*.json ./ +COPY apps/backend/package*.json ./ RUN npm ci -COPY . . +COPY apps/backend/ . +COPY packages/ /packages/ RUN npm run build FROM node:20-alpine AS runner @@ -13,7 +14,7 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production -COPY package*.json ./ +COPY apps/backend/package*.json ./ RUN npm ci --only=production COPY --from=builder /app/dist ./dist @@ -21,4 +22,4 @@ COPY --from=builder /app/dist ./dist EXPOSE 3000 ENV PORT=3000 -CMD ["node", "dist/src/main.js"] +CMD ["node", "dist/app/src/main.js"] diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 8c2c781..88ee2a2 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { DigestsModule } from './digests/digests.module'; import { CacheModule } from './common/cache/cache.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { RealtimeModule } from './realtime/realtime.module'; +import { OsFinderModule } from './os-finder/os-finder.module'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { RealtimeModule } from './realtime/realtime.module'; DigestsModule, AnalyticsModule, RealtimeModule, + OsFinderModule, ], controllers: [AppController], providers: [ diff --git a/apps/backend/src/database/migrations/202605210001-create-os-finder-tables.ts b/apps/backend/src/database/migrations/202605210001-create-os-finder-tables.ts new file mode 100644 index 0000000..aa16f56 --- /dev/null +++ b/apps/backend/src/database/migrations/202605210001-create-os-finder-tables.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateOsFinderTables1779200000000 implements MigrationInterface { + name = 'CreateOsFinderTables1779200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "saved_repos" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "github_repo_id" bigint NOT NULL, + "owner" character varying NOT NULL, + "name" character varying NOT NULL, + "full_name" character varying NOT NULL, + "description" text, + "language" character varying, + "stars" integer NOT NULL DEFAULT 0, + "forks" integer NOT NULL DEFAULT 0, + "open_issues" integer NOT NULL DEFAULT 0, + "ncf_score" double precision, + "lang_match_score" double precision, + "last_commit_at" TIMESTAMP, + "has_contributing" boolean DEFAULT false, + "license_type" character varying, + "html_url" character varying NOT NULL, + "saved_at" TIMESTAMP NOT NULL DEFAULT now(), + "notes" text, + "status" character varying NOT NULL DEFAULT 'saved', + CONSTRAINT "PK_saved_repos_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_saved_repos_user_github" UNIQUE ("user_id", "github_repo_id"), + CONSTRAINT "FK_saved_repos_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_saved_repos_user" ON "saved_repos" ("user_id") + `); + + await queryRunner.query(` + CREATE TABLE "os_finder_searches" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "query_text" text, + "filters_applied" jsonb NOT NULL, + "result_count" integer, + "ai_query_used" boolean NOT NULL DEFAULT false, + "github_query" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_os_finder_searches_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_os_finder_searches_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(` + CREATE INDEX "idx_os_finder_searches_user" ON "os_finder_searches" ("user_id", "created_at" DESC) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "idx_os_finder_searches_user"`); + await queryRunner.query(`DROP TABLE IF EXISTS "os_finder_searches"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_saved_repos_user"`); + await queryRunner.query(`DROP TABLE IF EXISTS "saved_repos"`); + } +} diff --git a/apps/backend/src/os-finder/ai-query-builder.service.ts b/apps/backend/src/os-finder/ai-query-builder.service.ts new file mode 100644 index 0000000..8a6c191 --- /dev/null +++ b/apps/backend/src/os-finder/ai-query-builder.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AiService } from '../shared/ai.service'; +import { OsFinderFilters, Difficulty, ContributionType, Domain, RepoSize } from '../../../../packages/shared-types/os-finder.types'; + +@Injectable() +export class AiQueryBuilderService { + private readonly logger = new Logger(AiQueryBuilderService.name); + + constructor(private readonly aiService: AiService) {} + + async buildFilters( + userQuery: string, + userCtx: { topLanguages: string[]; inferredLevel: string; avgPRScore: number } + ): Promise<{ filters: OsFinderFilters; keywords: string[]; aiModeUsed: boolean; fallbackUsed: boolean }> { + const prompt = `You are a GitHub repository search assistant inside DevPulse. +Given a developer's natural language request and their profile, +extract a structured search filter object. Respond ONLY with valid JSON. + +Developer profile: +- Top languages: ${userCtx.topLanguages.join(', ')} +- Experience level (inferred): ${userCtx.inferredLevel} +- Avg PR quality score: ${userCtx.avgPRScore.toFixed(1)}/10 + +User request: "${userQuery}" + +Return this exact JSON structure: +{ + "languages": string[], + "languageMode": "strict" | "any_of", + "difficulty": "beginner" | "intermediate" | "advanced", + "contributionTypes": string[], + "domains": string[], + "repoSize": "small" | "medium" | "large" | "any", + "lastCommitDays": number, + "hasContributing": boolean, + "keywords": string[] +} + +Rules: +- If user doesn't mention language, use their top language from profile +- If user says 'nothing too complex' or 'beginner-friendly', set difficulty: beginner +- If user says 'active community', set lastCommitDays: 30 +- Never invent filters not in the schema above +- Only respond with JSON — no preamble, no markdown backticks`; + + const timeoutMs = 5000; + let aiResponse: string | null = null; + let fallbackUsed = false; + + try { + // 5-second hard timeout + aiResponse = await Promise.race([ + this.aiService.generateText(prompt), + new Promise((_, reject) => + setTimeout(() => reject(new Error('AI Call Timeout')), timeoutMs) + ), + ]); + } catch (error) { + this.logger.warn(`AI Query Builder failed or timed out: ${error instanceof Error ? error.message : String(error)}. Using keyword fallback.`); + fallbackUsed = true; + } + + if (!aiResponse || fallbackUsed) { + return { + ...this.parseFallback(userQuery, userCtx), + aiModeUsed: true, + fallbackUsed: true, + }; + } + + try { + // Clean possible JSON formatting issues (like markdown ticks) + let cleaned = aiResponse.trim(); + if (cleaned.startsWith('```json')) { + cleaned = cleaned.substring(7); + } + if (cleaned.startsWith('```')) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith('```')) { + cleaned = cleaned.substring(0, cleaned.length - 3); + } + cleaned = cleaned.trim(); + + const parsed = JSON.parse(cleaned); + + const filters: OsFinderFilters = { + languages: Array.isArray(parsed.languages) ? parsed.languages : [userCtx.topLanguages[0] || 'typescript'], + languageMode: parsed.languageMode || 'any_of', + difficulty: parsed.difficulty || (userCtx.inferredLevel as Difficulty), + contributionTypes: Array.isArray(parsed.contributionTypes) ? parsed.contributionTypes as ContributionType[] : [], + domains: Array.isArray(parsed.domains) ? parsed.domains as Domain[] : [], + repoSize: parsed.repoSize || 'any', + lastCommitDays: typeof parsed.lastCommitDays === 'number' ? parsed.lastCommitDays : 90, + minOpenIssues: 3, + issueFreshDays: 60, + hasContributing: typeof parsed.hasContributing === 'boolean' ? parsed.hasContributing : true, + hasCodeOfConduct: false, + licenseTypes: [], + prMergeRate: 30, + }; + + const keywords = Array.isArray(parsed.keywords) ? parsed.keywords : []; + + return { + filters, + keywords, + aiModeUsed: true, + fallbackUsed: false, + }; + } catch (parseError) { + this.logger.error(`Failed to parse AI JSON response: ${parseError instanceof Error ? parseError.message : String(parseError)}. Content was: ${aiResponse}`); + return { + ...this.parseFallback(userQuery, userCtx), + aiModeUsed: true, + fallbackUsed: true, + }; + } + } + + private parseFallback( + query: string, + userCtx: { topLanguages: string[]; inferredLevel: string } + ): { filters: OsFinderFilters; keywords: string[] } { + const text = query.toLowerCase(); + + // 1. Detect language keywords + const COMMON_LANGUAGES = ['typescript', 'javascript', 'python', 'rust', 'go', 'cpp', 'c++', 'java', 'ruby', 'php', 'html', 'css', 'swift', 'kotlin']; + const detectedLanguages: string[] = []; + COMMON_LANGUAGES.forEach(lang => { + // match full word + const regex = new RegExp(`\\b${lang.replace('+', '\\+')}\\b`, 'i'); + if (regex.test(text)) { + detectedLanguages.push(lang); + } + }); + + // Default to user's top language if none found + if (detectedLanguages.length === 0 && userCtx.topLanguages.length > 0) { + detectedLanguages.push(userCtx.topLanguages[0]); + } else if (detectedLanguages.length === 0) { + detectedLanguages.push('typescript'); // final fallback + } + + // 2. Detect difficulty + let difficulty: Difficulty = userCtx.inferredLevel as Difficulty; + if (/\b(beginner|simple|easy|first issue|starter|newbie)\b/i.test(text)) { + difficulty = 'beginner'; + } else if (/\b(intermediate|medium|average)\b/i.test(text)) { + difficulty = 'intermediate'; + } else if (/\b(advanced|complex|hard|expert|niche|guru)\b/i.test(text)) { + difficulty = 'advanced'; + } + + // 3. Detect domains + const domainKeywords: { domain: Domain; keywords: string[] }[] = [ + { domain: 'web', keywords: ['web', 'frontend', 'backend', 'api', 'react', 'nextjs', 'nodejs', 'website'] }, + { domain: 'devtools', keywords: ['cli', 'tool', 'terminal', 'devtools', 'npm', 'scripts'] }, + { domain: 'ai_ml', keywords: ['ml', 'ai', 'learning', 'gpt', 'llm', 'neural', 'weights', 'inference'] }, + { domain: 'mobile', keywords: ['mobile', 'android', 'ios', 'flutter', 'react native', 'phone', 'app'] }, + { domain: 'data', keywords: ['data', 'visualization', 'science', 'database', 'sql', 'analysis'] }, + { domain: 'infrastructure', keywords: ['devops', 'infrastructure', 'docker', 'kubernetes', 'cloud', 'ci', 'cd'] }, + { domain: 'education', keywords: ['learn', 'education', 'tutorial', 'course', 'school', 'academy'] }, + { domain: 'games', keywords: ['game', 'gameplay', 'gamedev', 'unity', 'unreal', 'physics', 'engine'] }, + { domain: 'finance', keywords: ['finance', 'blockchain', 'crypto', 'trading', 'ledger', 'wallet'] }, + ]; + + const detectedDomains: Domain[] = []; + domainKeywords.forEach(dk => { + const match = dk.keywords.some(keyword => { + const regex = new RegExp(`\\b${keyword}\\b`, 'i'); + return regex.test(text); + }); + if (match) { + detectedDomains.push(dk.domain); + } + }); + + // 4. Remaining keywords extraction + // Split into words, exclude common stop words, languages and domain terms + const stopWords = new Set(['i', 'want', 'to', 'contribute', 'something', 'related', 'in', 'the', 'a', 'an', 'and', 'for', 'with', 'on', 'in', 'of', 'at', 'by', 'project', 'projects', 'repo', 'repos', 'repository', 'repositories']); + const allWords = text.split(/[^a-zA-Z0-9\+\#]/).filter(Boolean); + + const keywords = allWords.filter(word => { + if (word.length < 3) return false; + if (stopWords.has(word)) return false; + if (COMMON_LANGUAGES.includes(word)) return false; + // also filter out domain match terms + const isDomainTerm = domainKeywords.some(dk => dk.keywords.includes(word)); + if (isDomainTerm) return false; + return true; + }); + + const filters: OsFinderFilters = { + languages: detectedLanguages, + languageMode: 'any_of', + difficulty, + contributionTypes: [], + domains: detectedDomains, + repoSize: 'any', + lastCommitDays: text.includes('active community') ? 30 : 90, + minOpenIssues: 3, + issueFreshDays: 60, + hasContributing: true, + hasCodeOfConduct: false, + licenseTypes: [], + prMergeRate: 30, + }; + + return { + filters, + keywords: keywords.slice(0, 5), // take first 5 keywords + }; + } +} diff --git a/apps/backend/src/os-finder/dto/save-repo.dto.ts b/apps/backend/src/os-finder/dto/save-repo.dto.ts new file mode 100644 index 0000000..ee0ee2a --- /dev/null +++ b/apps/backend/src/os-finder/dto/save-repo.dto.ts @@ -0,0 +1,72 @@ +import { IsNotEmpty, IsNumber, IsString, IsOptional, IsBoolean, IsEnum } from 'class-validator'; +import type { SavedRepoStatus } from '../../../../../packages/shared-types/os-finder.types'; + +export class SaveRepoDto { + @IsNotEmpty() + @IsNumber() + githubRepoId: number; + + @IsNotEmpty() + @IsString() + owner: string; + + @IsNotEmpty() + @IsString() + name: string; + + @IsNotEmpty() + @IsString() + fullName: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsString() + language?: string | null; + + @IsOptional() + @IsNumber() + stars?: number; + + @IsOptional() + @IsNumber() + forks?: number; + + @IsOptional() + @IsNumber() + openIssues?: number; + + @IsOptional() + @IsNumber() + ncfScore?: number | null; + + @IsOptional() + @IsNumber() + langMatchScore?: number | null; + + @IsOptional() + @IsString() + lastCommitAt?: string | null; + + @IsOptional() + @IsBoolean() + hasContributing?: boolean; + + @IsOptional() + @IsString() + licenseType?: string | null; + + @IsNotEmpty() + @IsString() + htmlUrl: string; + + @IsOptional() + @IsString() + notes?: string | null; + + @IsOptional() + @IsEnum(['saved', 'contributed', 'skipped']) + status?: SavedRepoStatus = 'saved'; +} diff --git a/apps/backend/src/os-finder/dto/search-query.dto.ts b/apps/backend/src/os-finder/dto/search-query.dto.ts new file mode 100644 index 0000000..0ca5255 --- /dev/null +++ b/apps/backend/src/os-finder/dto/search-query.dto.ts @@ -0,0 +1,115 @@ +import { IsOptional, IsString, IsEnum, IsNumber, IsBoolean, IsArray, IsInt, Min, Max } from 'class-validator'; +import { Transform } from 'class-transformer'; +import type { Difficulty, ContributionType, Domain, RepoSize, LanguageMode } from '../../../../../packages/shared-types/os-finder.types'; + +export class SearchQueryDto { + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => { + if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(Boolean); + return value; + }) + languages?: string[]; + + @IsOptional() + @IsEnum(['strict', 'any_of']) + languageMode?: LanguageMode = 'any_of'; + + @IsOptional() + @IsEnum(['beginner', 'intermediate', 'advanced']) + difficulty?: Difficulty; + + @IsOptional() + @IsArray() + @Transform(({ value }) => { + if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(Boolean); + return value; + }) + contributionTypes?: ContributionType[]; + + @IsOptional() + @IsArray() + @Transform(({ value }) => { + if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(Boolean); + return value; + }) + domains?: Domain[]; + + @IsOptional() + @IsEnum(['small', 'medium', 'large', 'any']) + repoSize?: RepoSize = 'any'; + + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => value !== undefined ? parseInt(value, 10) : undefined) + lastCommitDays?: number = 90; + + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => value !== undefined ? parseInt(value, 10) : undefined) + minOpenIssues?: number = 3; + + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => value !== undefined ? parseInt(value, 10) : undefined) + issueFreshDays?: number = 60; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + hasContributing?: boolean = true; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + hasCodeOfConduct?: boolean = false; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => { + if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(Boolean); + return value; + }) + licenseTypes?: string[]; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + @Transform(({ value }) => value !== undefined ? parseFloat(value) : undefined) + prMergeRate?: number = 30; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + includeAlreadyContributed?: boolean = false; + + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => value !== undefined ? parseInt(value, 10) : undefined) + page?: number = 1; + + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => value !== undefined ? parseInt(value, 10) : undefined) + limit?: number = 30; +} diff --git a/apps/backend/src/os-finder/dto/update-saved-repo.dto.ts b/apps/backend/src/os-finder/dto/update-saved-repo.dto.ts new file mode 100644 index 0000000..b795f5e --- /dev/null +++ b/apps/backend/src/os-finder/dto/update-saved-repo.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString, IsEnum } from 'class-validator'; +import type { SavedRepoStatus } from '../../../../../packages/shared-types/os-finder.types'; + +export class UpdateSavedRepoDto { + @IsOptional() + @IsString() + notes?: string | null; + + @IsOptional() + @IsEnum(['saved', 'contributed', 'skipped']) + status?: SavedRepoStatus; +} diff --git a/apps/backend/src/os-finder/entities/os-finder-search.entity.ts b/apps/backend/src/os-finder/entities/os-finder-search.entity.ts new file mode 100644 index 0000000..09a314a --- /dev/null +++ b/apps/backend/src/os-finder/entities/os-finder-search.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('os_finder_searches') +@Index(['userId', 'createdAt']) +export class OsFinderSearch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'query_text', type: 'text', nullable: true }) + queryText: string | null; + + @Column({ name: 'filters_applied', type: 'jsonb' }) + filtersApplied: any; + + @Column({ name: 'result_count', type: 'int', nullable: true }) + resultCount: number | null; + + @Column({ name: 'ai_query_used', type: 'boolean', default: false }) + aiQueryUsed: boolean; + + @Column({ name: 'github_query', type: 'text', nullable: true }) + githubQuery: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/backend/src/os-finder/entities/saved-repo.entity.ts b/apps/backend/src/os-finder/entities/saved-repo.entity.ts new file mode 100644 index 0000000..fc29b69 --- /dev/null +++ b/apps/backend/src/os-finder/entities/saved-repo.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Unique, + Index, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('saved_repos') +@Unique(['userId', 'githubRepoId']) +@Index(['userId']) +export class SavedRepo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'github_repo_id', type: 'bigint' }) + githubRepoId: number; + + @Column({ type: 'varchar' }) + owner: string; + + @Column({ type: 'varchar' }) + name: string; + + @Column({ name: 'full_name', type: 'varchar' }) + fullName: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', nullable: true }) + language: string | null; + + @Column({ type: 'int', default: 0 }) + stars: number; + + @Column({ type: 'int', default: 0 }) + forks: number; + + @Column({ name: 'open_issues', type: 'int', default: 0 }) + openIssues: number; + + @Column({ name: 'ncf_score', type: 'double precision', nullable: true }) + ncfScore: number | null; + + @Column({ name: 'lang_match_score', type: 'double precision', nullable: true }) + langMatchScore: number | null; + + @Column({ name: 'last_commit_at', type: 'timestamp', nullable: true }) + lastCommitAt: Date | null; + + @Column({ name: 'has_contributing', type: 'boolean', default: false }) + hasContributing: boolean; + + @Column({ name: 'license_type', type: 'varchar', nullable: true }) + licenseType: string | null; + + @Column({ name: 'html_url', type: 'varchar' }) + htmlUrl: string; + + @CreateDateColumn({ name: 'saved_at' }) + savedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'varchar', default: 'saved' }) + status: string; // 'saved' | 'contributed' | 'skipped' +} diff --git a/apps/backend/src/os-finder/github-query-builder.spec.ts b/apps/backend/src/os-finder/github-query-builder.spec.ts new file mode 100644 index 0000000..e6d8a28 --- /dev/null +++ b/apps/backend/src/os-finder/github-query-builder.spec.ts @@ -0,0 +1,137 @@ +import { GitHubQueryBuilder } from './github-query-builder'; +import { OsFinderFilters } from '../../../../packages/shared-types/os-finder.types'; + +describe('GitHubQueryBuilder', () => { + const baseFilters: OsFinderFilters = { + languages: [], + languageMode: 'any_of', + contributionTypes: [], + domains: [], + repoSize: 'any', + lastCommitDays: 90, + minOpenIssues: 3, + issueFreshDays: 60, + hasContributing: true, + hasCodeOfConduct: false, + licenseTypes: [], + prMergeRate: 30, + }; + + const getPushedDateStr = (days: number): string => { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString().slice(0, 10); + }; + + it('1. should generate base query with defaults', () => { + const query = GitHubQueryBuilder.build(baseFilters); + const expectedPushed = getPushedDateStr(90); + expect(query).toContain('archived:false'); + expect(query).toContain(`pushed:>=${expectedPushed}`); + }); + + it('2. should append single language filter', () => { + const filters = { ...baseFilters, languages: ['typescript'] }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('language:typescript'); + }); + + it('3. should group multiple languages with OR', () => { + const filters = { ...baseFilters, languages: ['typescript', 'python'] }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('(language:typescript OR language:python)'); + }); + + it('4. should use user context languages as fallback', () => { + const query = GitHubQueryBuilder.build(baseFilters, { topLanguages: ['javascript', 'go'], inferredLevel: 'beginner' }); + expect(query).toContain('(language:javascript OR language:go)'); + }); + + it('5. should handle small repo size stars filter', () => { + const filters = { ...baseFilters, repoSize: 'small' as const }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('stars:1..999'); + }); + + it('6. should handle medium repo size stars filter', () => { + const filters = { ...baseFilters, repoSize: 'medium' as const }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('stars:1000..10000'); + }); + + it('7. should handle large repo size stars filter', () => { + const filters = { ...baseFilters, repoSize: 'large' as const }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('stars:10001..100000'); + }); + + it('8. should append good-first-issues filter for beginner difficulty', () => { + const filters = { ...baseFilters, difficulty: 'beginner' as const }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('good-first-issues:>0'); + }); + + it('9. should append help-wanted-issues filter for intermediate difficulty', () => { + const filters = { ...baseFilters, difficulty: 'intermediate' as const }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('help-wanted-issues:>0'); + }); + + it('10. should infer beginner level from user context', () => { + const query = GitHubQueryBuilder.build(baseFilters, { topLanguages: [], inferredLevel: 'beginner' }); + expect(query).toContain('good-first-issues:>0'); + }); + + it('11. should infer intermediate level from user context', () => { + const query = GitHubQueryBuilder.build(baseFilters, { topLanguages: [], inferredLevel: 'intermediate' }); + expect(query).toContain('help-wanted-issues:>0'); + }); + + it('12. should handle single domain mapped to topics', () => { + const filters = { ...baseFilters, domains: ['devtools' as const] }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('(topic:cli OR topic:developer-tools OR topic:devtools OR topic:terminal)'); + }); + + it('13. should handle multiple domains mapped to topics', () => { + const filters = { ...baseFilters, domains: ['devtools' as const, 'ai_ml' as const] }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('topic:cli'); + expect(query).toContain('topic:machine-learning'); + }); + + it('14. should append single license filter', () => { + const filters = { ...baseFilters, licenseTypes: ['MIT'] }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('license:mit'); + }); + + it('15. should append multiple license filters grouped with OR', () => { + const filters = { ...baseFilters, licenseTypes: ['MIT', 'Apache-2.0'] }; + const query = GitHubQueryBuilder.build(filters); + expect(query).toContain('(license:mit OR license:apache-2.0)'); + }); + + it('16. should prepend keywords to query', () => { + const query = GitHubQueryBuilder.build(baseFilters, undefined, ['cli-tool', 'rust helper']); + expect(query.startsWith('cli-tool "rust helper"')).toBe(true); + }); + + it('17. should combine all filters correctly', () => { + const filters = { + ...baseFilters, + languages: ['typescript'], + difficulty: 'beginner' as const, + domains: ['web' as const], + repoSize: 'small' as const, + licenseTypes: ['MIT'], + }; + const query = GitHubQueryBuilder.build(filters, undefined, ['editor']); + expect(query).toContain('editor'); + expect(query).toContain('language:typescript'); + expect(query).toContain('good-first-issues:>0'); + expect(query).toContain('stars:1..999'); + expect(query).toContain('topic:web'); + expect(query).toContain('license:mit'); + }); +}); diff --git a/apps/backend/src/os-finder/github-query-builder.ts b/apps/backend/src/os-finder/github-query-builder.ts new file mode 100644 index 0000000..1c33a32 --- /dev/null +++ b/apps/backend/src/os-finder/github-query-builder.ts @@ -0,0 +1,110 @@ +import { OsFinderFilters } from '../../../../packages/shared-types/os-finder.types'; + +export class GitHubQueryBuilder { + static build( + filters: OsFinderFilters, + userCtx?: { topLanguages: string[]; inferredLevel: string }, + keywords?: string[] + ): string { + const parts: string[] = []; + + // 1. Language filter + const langs = filters.languages && filters.languages.length > 0 + ? filters.languages + : (userCtx?.topLanguages || []); + + if (langs.length > 0) { + const langQueries = langs.map(lang => `language:${lang}`); + if (langQueries.length === 1) { + parts.push(langQueries[0]); + } else { + parts.push(`(${langQueries.join(' OR ')})`); + } + } + + // 2. Exclude archived + parts.push('archived:false'); + + // 3. Last commit days (activity) + if (filters.lastCommitDays) { + const date = new Date(); + date.setDate(date.getDate() - filters.lastCommitDays); + const YYYYMMDD = date.toISOString().slice(0, 10); + parts.push(`pushed:>=${YYYYMMDD}`); + } + + // 4. Repo size (stars) + if (filters.repoSize && filters.repoSize !== 'any') { + const STAR_RANGE_MAP = { + small: '1..999', + medium: '1000..10000', + large: '10001..100000', + }; + const range = STAR_RANGE_MAP[filters.repoSize as keyof typeof STAR_RANGE_MAP]; + if (range) { + parts.push(`stars:${range}`); + } + } + + // 5. Difficulty issue filters + const diff = filters.difficulty || userCtx?.inferredLevel; + if (diff === 'beginner') { + parts.push('good-first-issues:>0'); + } else if (diff === 'intermediate') { + parts.push('help-wanted-issues:>0'); + } + + // 6. Domains / Topics + if (filters.domains && filters.domains.length > 0) { + const DOMAIN_TOPIC_MAP: Record = { + web: ['web', 'frontend', 'backend', 'react', 'nextjs', 'nodejs'], + devtools: ['cli', 'developer-tools', 'devtools', 'terminal'], + ai_ml: ['machine-learning', 'artificial-intelligence', 'llm'], + mobile: ['mobile', 'react-native', 'flutter', 'ios', 'android'], + data: ['data-science', 'analytics', 'pandas', 'visualization'], + infrastructure: ['devops', 'docker', 'kubernetes', 'ci-cd', 'cloud'], + education: ['education', 'learning', 'tutorial', 'course'], + games: ['game', 'game-development', 'gamedev'], + finance: ['finance', 'fintech', 'trading', 'blockchain'], + }; + + const topics: string[] = []; + filters.domains.forEach(domain => { + const mapped = DOMAIN_TOPIC_MAP[domain]; + if (mapped) { + topics.push(...mapped); + } + }); + + if (topics.length > 0) { + const topicQueries = topics.map(t => `topic:${t}`); + if (topicQueries.length === 1) { + parts.push(topicQueries[0]); + } else { + parts.push(`(${topicQueries.join(' OR ')})`); + } + } + } + + // 7. License types + if (filters.licenseTypes && filters.licenseTypes.length > 0) { + const licenseQueries = filters.licenseTypes.map(lic => `license:${lic.toLowerCase()}`); + if (licenseQueries.length === 1) { + parts.push(licenseQueries[0]); + } else { + parts.push(`(${licenseQueries.join(' OR ')})`); + } + } + + // 8. Keywords + if (keywords && keywords.length > 0) { + // Escape spaces or quote them if needed, or join simply + const kw = keywords.filter(Boolean).map(k => k.includes(' ') ? `"${k}"` : k); + if (kw.length > 0) { + parts.unshift(...kw); + } + } + + return parts.join(' '); + } +} diff --git a/apps/backend/src/os-finder/ncf-scorer.service.spec.ts b/apps/backend/src/os-finder/ncf-scorer.service.spec.ts new file mode 100644 index 0000000..e8ef569 --- /dev/null +++ b/apps/backend/src/os-finder/ncf-scorer.service.spec.ts @@ -0,0 +1,163 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NcfScorerService } from './ncf-scorer.service'; +import { OsFinderCacheService } from './os-finder-cache.service'; + +describe('NcfScorerService', () => { + let service: NcfScorerService; + let cacheService: OsFinderCacheService; + + const mockCacheService = { + getRepoIssues: jest.fn(), + setRepoIssues: jest.fn(), + getPRStats: jest.fn(), + setPRStats: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NcfScorerService, + { provide: OsFinderCacheService, useValue: mockCacheService }, + ], + }).compile(); + + service = module.get(NcfScorerService); + cacheService = module.get(OsFinderCacheService); + }); + + afterEach(() => { + jest.clearAllMocks(); + if ((global.fetch as any).mockRestore) { + (global.fetch as any).mockRestore(); + } + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should compute full NCF score when all indicators are optimal', async () => { + mockCacheService.getRepoIssues.mockResolvedValue(null); + mockCacheService.getPRStats.mockResolvedValue({ mergeRate: 80 }); + + const freshDate = new Date(); + freshDate.setDate(freshDate.getDate() - 5); + + // Mock global fetch + const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation((url: any) => { + let data: any = {}; + if (url.includes('/issues?labels=good first issue')) { + data = [{ id: 1, updated_at: freshDate.toISOString() }]; + } else if (url.includes('/issues?labels=help wanted')) { + data = [{ id: 2 }]; + } else if (url.includes('/community/profile')) { + data = { + files: { + contributing: { html_url: 'contrib' }, + code_of_conduct: { html_url: 'coc' }, + }, + }; + } else if (url.includes('/readme')) { + data = { size: 5000 }; + } else if (url.includes('/pulls')) { + data = [ + { + merged_at: freshDate.toISOString(), + author_association: 'FIRST_TIME_CONTRIBUTOR', + }, + ]; + } else if (url.includes('/issues?state=closed')) { + data = [ + { + created_at: freshDate.toISOString(), + closed_at: freshDate.toISOString(), + }, + ]; + } + + return Promise.resolve({ + ok: true, + status: 200, + headers: new Map([ + ['X-RateLimit-Remaining', '1000'], + ['X-RateLimit-Reset', '1234567'], + ]), + json: () => Promise.resolve(data), + } as any); + }); + + const breakdown = await service.computeNCFScore('owner', 'repo', 'token', {}); + + expect(breakdown.goodFirstIssue).toBe(2.5); + expect(breakdown.helpWanted).toBe(1.0); + expect(breakdown.contributingFile).toBe(1.5); + expect(breakdown.codeOfConduct).toBe(0.5); + expect(breakdown.readmeQuality).toBe(0.5); + expect(breakdown.prMergeRate).toBe(0.5); + expect(breakdown.issueResponseTime).toBe(2.0); + expect(breakdown.newContribPR).toBe(1.5); + expect(breakdown.total).toBe(10.0); + }); + + it('should compute low NCF score when all indicators are absent or poor', async () => { + mockCacheService.getRepoIssues.mockResolvedValue(null); + mockCacheService.getPRStats.mockResolvedValue({ mergeRate: 5 }); + + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); + + const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation((url: any) => { + let data: any = {}; + if (url.includes('/issues?labels=good first issue')) { + data = []; + } else if (url.includes('/issues?labels=help wanted')) { + data = []; + } else if (url.includes('/community/profile')) { + data = { + files: { + contributing: null, + code_of_conduct: null, + }, + }; + } else if (url.includes('/readme')) { + data = { size: 100 }; + } else if (url.includes('/pulls')) { + data = []; + } else if (url.includes('/issues?state=closed')) { + // slow close time: 40 days + const start = new Date(); + start.setDate(start.getDate() - 50); + const end = new Date(); + end.setDate(end.getDate() - 10); + data = [ + { + created_at: start.toISOString(), + closed_at: end.toISOString(), + }, + ]; + } + + return Promise.resolve({ + ok: true, + status: 200, + headers: new Map([ + ['X-RateLimit-Remaining', '1000'], + ['X-RateLimit-Reset', '1234567'], + ]), + json: () => Promise.resolve(data), + } as any); + }); + + const breakdown = await service.computeNCFScore('owner', 'repo', 'token', {}); + + expect(breakdown.goodFirstIssue).toBe(0); + expect(breakdown.helpWanted).toBe(0); + expect(breakdown.contributingFile).toBe(0); + expect(breakdown.codeOfConduct).toBe(0); + expect(breakdown.readmeQuality).toBe(0); + expect(breakdown.prMergeRate).toBe(0); + expect(breakdown.issueResponseTime).toBe(0); // slow avgDaysToClose (40 days) => 0 points + expect(breakdown.newContribPR).toBe(0); + expect(breakdown.total).toBe(1.0); // base score of 1.0 minimum + }); +}); diff --git a/apps/backend/src/os-finder/ncf-scorer.service.ts b/apps/backend/src/os-finder/ncf-scorer.service.ts new file mode 100644 index 0000000..9982cd6 --- /dev/null +++ b/apps/backend/src/os-finder/ncf-scorer.service.ts @@ -0,0 +1,229 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OsFinderCacheService } from './os-finder-cache.service'; +import { NCFScoreBreakdown } from '../../../../packages/shared-types/os-finder.types'; +import { GithubRateLimitError } from './repo-health.service'; + +@Injectable() +export class NcfScorerService { + private readonly logger = new Logger(NcfScorerService.name); + private readonly GITHUB_API_BASE = 'https://api.github.com'; + + constructor(private readonly cacheService: OsFinderCacheService) {} + + private async fetchGithub(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'DevPulse Backend', + }, + }); + + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + + if (remaining) { + const remainingVal = parseInt(remaining, 10); + if (remainingVal < 50) { + const resetTime = reset ? parseInt(reset, 10) * 1000 : Date.now() + 60000; + this.logger.warn(`GitHub API Rate limit low during NCF score compute: ${remainingVal} remaining. Reset in ${Math.round((resetTime - Date.now()) / 1000)}s`); + throw new GithubRateLimitError(resetTime); + } + } + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`GitHub API NCF fetch failed: ${response.status} ${response.statusText} on ${url}`); + } + + return response.json(); + } + + async computeNCFScore( + owner: string, + repo: string, + token: string, + repoDetails: any + ): Promise { + const breakdown: NCFScoreBreakdown = { + total: 1.0, // base score + goodFirstIssue: 0, + helpWanted: 0, + contributingFile: 0, + issueResponseTime: 0, + newContribPR: 0, + readmeQuality: 0, + codeOfConduct: 0, + prMergeRate: 0, + }; + + try { + let score = 0; + + // 1. Open and Fresh 'good first issue' labels (weight 25% = 2.5 pts) + let goodFirstIssues = await this.cacheService.getRepoIssues(owner, repo, ['good first issue']); + if (!goodFirstIssues) { + goodFirstIssues = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/issues?labels=good first issue&state=open&per_page=10`, + token + ); + if (goodFirstIssues) { + await this.cacheService.setRepoIssues(owner, repo, ['good first issue'], goodFirstIssues); + } + } + + if (Array.isArray(goodFirstIssues) && goodFirstIssues.length > 0) { + // Check for freshness: updated within 60 days + const sixtyDaysAgo = Date.now() - 60 * 24 * 60 * 60 * 1000; + const hasFresh = goodFirstIssues.some( + issue => !issue.pull_request && new Date(issue.updated_at).getTime() > sixtyDaysAgo + ); + if (hasFresh) { + breakdown.goodFirstIssue = 2.5; + score += 2.5; + } + } + + // 2. Has 'help wanted' labels (weight 10% = 1.0 pt) + let helpWanted = await this.cacheService.getRepoIssues(owner, repo, ['help wanted']); + if (!helpWanted) { + helpWanted = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/issues?labels=help wanted&state=open&per_page=5`, + token + ); + if (helpWanted) { + await this.cacheService.setRepoIssues(owner, repo, ['help wanted'], helpWanted); + } + } + + if (Array.isArray(helpWanted) && helpWanted.length > 0) { + breakdown.helpWanted = 1.0; + score += 1.0; + } + + // Fetch Community Profile & README metadata + const communityProfile = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/community/profile`, + token + ); + + if (communityProfile) { + // 3. CONTRIBUTING file (weight 15% = 1.5 pts) + if (communityProfile.files?.contributing) { + breakdown.contributingFile = 1.5; + score += 1.5; + } + + // 7. Code of Conduct present (weight 5% = 0.5 pts) + if (communityProfile.files?.code_of_conduct) { + breakdown.codeOfConduct = 0.5; + score += 0.5; + } + } + + // 6. README quality: check word count proxy via README file size (weight 5% = 0.5 pts) + const readmeMeta = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/readme`, + token + ); + if (readmeMeta && readmeMeta.size > 2000) { + breakdown.readmeQuality = 0.5; + score += 0.5; + } + + // 8. PR Merge Rate (weight 5% = 0.5 pts) + const prStatsCache = await this.cacheService.getPRStats(owner, repo); + let prStats = prStatsCache; + if (!prStats) { + const closedPRs = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?state=closed&per_page=30`, + token + ); + if (Array.isArray(closedPRs) && closedPRs.length > 0) { + const mergedCount = closedPRs.filter(pr => pr.merged_at).length; + const mergeRate = (mergedCount / closedPRs.length) * 100; + prStats = { mergeRate }; + await this.cacheService.setPRStats(owner, repo, prStats); + } else { + prStats = { mergeRate: 100 }; + } + } + + if (prStats.mergeRate > 50) { + breakdown.prMergeRate = 0.5; + score += 0.5; + } else if (prStats.mergeRate > 30) { + breakdown.prMergeRate = 0.25; + score += 0.25; + } + + // 4. Avg issue response time proxy: close time of closed issues (weight 20% = 2.0 pts) + const closedIssues = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/issues?state=closed&per_page=20`, + token + ); + + let avgCloseDays = 999; + if (Array.isArray(closedIssues) && closedIssues.length > 0) { + const cleanIssues = closedIssues.filter(item => !item.pull_request); + if (cleanIssues.length > 0) { + let totalCloseDays = 0; + cleanIssues.forEach(issue => { + const created = new Date(issue.created_at).getTime(); + const closed = new Date(issue.closed_at || Date.now()).getTime(); + totalCloseDays += (closed - created) / (1000 * 60 * 60 * 24); + }); + avgCloseDays = totalCloseDays / cleanIssues.length; + } + } + + if (avgCloseDays < 3) { + breakdown.issueResponseTime = 2.0; + score += 2.0; + } else if (avgCloseDays < 7) { + breakdown.issueResponseTime = 1.5; + score += 1.5; + } else if (avgCloseDays < 14) { + breakdown.issueResponseTime = 1.0; + score += 1.0; + } else if (avgCloseDays < 30) { + breakdown.issueResponseTime = 0.5; + score += 0.5; + } + + // 5. Recent merged PR from first time contributor (weight 15% = 1.5 pts) + const closedPRs = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?state=closed&per_page=20`, + token + ); + + if (Array.isArray(closedPRs) && closedPRs.length > 0) { + const sixtyDaysAgo = Date.now() - 60 * 24 * 60 * 60 * 1000; + const newContribPR = closedPRs.find(pr => { + if (!pr.merged_at) return false; + const mergedTime = new Date(pr.merged_at).getTime(); + if (mergedTime < sixtyDaysAgo) return false; + // check association + return pr.author_association === 'FIRST_TIME_CONTRIBUTOR' || pr.author_association === 'NONE'; + }); + + if (newContribPR) { + breakdown.newContribPR = 1.5; + score += 1.5; + } + } + + // Scale final score: base score of 1.0 up to a maximum of 10.0 + breakdown.total = Math.max(1.0, Math.min(score, 10.0)); + } catch (error) { + if (error instanceof GithubRateLimitError) { + throw error; + } + this.logger.error(`Error computing NCF score for ${owner}/${repo}: ${error instanceof Error ? error.message : String(error)}`); + } + + return breakdown; + } +} diff --git a/apps/backend/src/os-finder/os-finder-cache.service.ts b/apps/backend/src/os-finder/os-finder-cache.service.ts new file mode 100644 index 0000000..f58a8f6 --- /dev/null +++ b/apps/backend/src/os-finder/os-finder-cache.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CacheService } from '../common/cache/cache.service'; +import * as crypto from 'crypto'; + +@Injectable() +export class OsFinderCacheService { + private readonly logger = new Logger(OsFinderCacheService.name); + + constructor(private readonly cacheService: CacheService) {} + + private md5(val: string): string { + return crypto.createHash('md5').update(val).digest('hex'); + } + + // 1. Search Results Cache + async getSearchResults(userId: string, filters: any): Promise { + const sortedFilters = this.sortObjectKeys(filters); + const filterHash = this.md5(JSON.stringify(sortedFilters)); + const key = `os_finder:${userId}:${filterHash}`; + return this.cacheService.get(key); + } + + async setSearchResults(userId: string, filters: any, results: any): Promise { + const sortedFilters = this.sortObjectKeys(filters); + const filterHash = this.md5(JSON.stringify(sortedFilters)); + const key = `os_finder:${userId}:${filterHash}`; + await this.cacheService.set(key, results, 1800); // 30 minutes TTL + } + + // 2. Repo Health Flags Cache + async getRepoHealth(owner: string, repo: string): Promise { + const key = `gh_repo_health:${owner.toLowerCase()}/${repo.toLowerCase()}`; + return this.cacheService.get(key); + } + + async setRepoHealth(owner: string, repo: string, health: any): Promise { + const key = `gh_repo_health:${owner.toLowerCase()}/${repo.toLowerCase()}`; + await this.cacheService.set(key, health, 14400); // 4 hours TTL + } + + // 3. Issues List Cache + async getRepoIssues(owner: string, repo: string, labels: string[]): Promise { + const labelHash = this.md5(labels.sort().join(',')); + const key = `gh_repo_issues:${owner.toLowerCase()}/${repo.toLowerCase()}:${labelHash}`; + return this.cacheService.get(key); + } + + async setRepoIssues(owner: string, repo: string, labels: string[], issues: any[]): Promise { + const labelHash = this.md5(labels.sort().join(',')); + const key = `gh_repo_issues:${owner.toLowerCase()}/${repo.toLowerCase()}:${labelHash}`; + await this.cacheService.set(key, issues, 3600); // 1 hour TTL + } + + // 4. PR Stats Cache + async getPRStats(owner: string, repo: string): Promise { + const key = `gh_repo_prstats:${owner.toLowerCase()}/${repo.toLowerCase()}`; + return this.cacheService.get(key); + } + + async setPRStats(owner: string, repo: string, stats: any): Promise { + const key = `gh_repo_prstats:${owner.toLowerCase()}/${repo.toLowerCase()}`; + await this.cacheService.set(key, stats, 21600); // 6 hours TTL + } + + // 5. Watchlist / Saved Repos Cache + async getSavedRepos(userId: string): Promise { + const key = `os_finder_saved:${userId}`; + return this.cacheService.get(key); + } + + async setSavedRepos(userId: string, saved: any[]): Promise { + const key = `os_finder_saved:${userId}`; + await this.cacheService.set(key, saved, 300); // 5 minutes TTL + } + + async invalidateSavedRepos(userId: string): Promise { + const key = `os_finder_saved:${userId}`; + await this.cacheService.delete(key); + } + + // Utility to recursively sort object keys to ensure consistent hashes + private sortObjectKeys(obj: any): any { + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(item => this.sortObjectKeys(item)); + } + const sortedKeys = Object.keys(obj).sort(); + const result: any = {}; + sortedKeys.forEach(key => { + result[key] = this.sortObjectKeys(obj[key]); + }); + return result; + } +} diff --git a/apps/backend/src/os-finder/os-finder.controller.ts b/apps/backend/src/os-finder/os-finder.controller.ts new file mode 100644 index 0000000..334966f --- /dev/null +++ b/apps/backend/src/os-finder/os-finder.controller.ts @@ -0,0 +1,109 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Query, + Param, + UseGuards, + HttpCode, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { User } from '../users/entities/user.entity'; +import { OsFinderService } from './os-finder.service'; +import { SearchQueryDto } from './dto/search-query.dto'; +import { SaveRepoDto } from './dto/save-repo.dto'; +import { UpdateSavedRepoDto } from './dto/update-saved-repo.dto'; + +@Controller('os-finder') +@UseGuards(JwtAuthGuard) +export class OsFinderController { + private readonly logger = new Logger(OsFinderController.name); + + constructor(private readonly osFinderService: OsFinderService) {} + + @Get('search') + async search( + @CurrentUser() user: User, + @Query() queryDto: SearchQueryDto, + ) { + this.logger.log(`User ${user.id} initiated a standard OS Finder search`); + return this.osFinderService.search(user.id, queryDto); + } + + @Post('search/ai') + @HttpCode(HttpStatus.OK) + async searchAi( + @CurrentUser() user: User, + @Body() body: { query: string }, + ) { + this.logger.log(`User ${user.id} initiated an AI-powered OS Finder search with query: "${body.query}"`); + return this.osFinderService.searchAi(user.id, body); + } + + @Get('repo/:owner/:repo') + async getRepoDetail( + @CurrentUser() user: User, + @Param('owner') owner: string, + @Param('repo') repo: string, + ) { + this.logger.log(`User ${user.id} requested repo details for ${owner}/${repo}`); + return this.osFinderService.getRepoDetail(user.id, owner, repo); + } + + @Get('repo/:owner/:repo/issues') + async getRepoIssues( + @CurrentUser() user: User, + @Param('owner') owner: string, + @Param('repo') repo: string, + ) { + this.logger.log(`User ${user.id} requested open issues for ${owner}/${repo}`); + return this.osFinderService.getRepoIssues(user.id, owner, repo); + } + + @Post('saved') + async saveRepo( + @CurrentUser() user: User, + @Body() saveRepoDto: SaveRepoDto, + ) { + this.logger.log(`User ${user.id} is saving/updating repo ${saveRepoDto.fullName} in watchlist`); + return this.osFinderService.saveRepo(user.id, saveRepoDto); + } + + @Get('saved') + async getSavedRepos(@CurrentUser() user: User) { + this.logger.log(`User ${user.id} requested saved repos watchlist`); + return this.osFinderService.getSavedRepos(user.id); + } + + @Patch('saved/:id') + async updateSavedRepo( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() updateSavedRepoDto: UpdateSavedRepoDto, + ) { + this.logger.log(`User ${user.id} is updating saved repo status/notes for item ${id}`); + return this.osFinderService.updateSavedRepo(user.id, id, updateSavedRepoDto); + } + + @Delete('saved/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteSavedRepo( + @CurrentUser() user: User, + @Param('id') id: string, + ) { + this.logger.log(`User ${user.id} is removing repo item ${id} from watchlist`); + await this.osFinderService.deleteSavedRepo(user.id, id); + } + + @Get('history') + async getSearchHistory(@CurrentUser() user: User) { + this.logger.log(`User ${user.id} requested search history`); + return this.osFinderService.getSearchHistory(user.id); + } +} diff --git a/apps/backend/src/os-finder/os-finder.module.ts b/apps/backend/src/os-finder/os-finder.module.ts new file mode 100644 index 0000000..12f5fb9 --- /dev/null +++ b/apps/backend/src/os-finder/os-finder.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CacheModule } from '../common/cache/cache.module'; +import { User } from '../users/entities/user.entity'; +import { Commit } from '../github-sync/entities/commit.entity'; +import { PullRequest } from '../github-sync/entities/pull-request.entity'; +import { AiService } from '../shared/ai.service'; +import { SavedRepo } from './entities/saved-repo.entity'; +import { OsFinderSearch } from './entities/os-finder-search.entity'; +import { OsFinderController } from './os-finder.controller'; +import { OsFinderService } from './os-finder.service'; +import { OsFinderCacheService } from './os-finder-cache.service'; +import { RepoHealthService } from './repo-health.service'; +import { NcfScorerService } from './ncf-scorer.service'; +import { AiQueryBuilderService } from './ai-query-builder.service'; + +@Module({ + imports: [ + CacheModule, + TypeOrmModule.forFeature([ + User, + SavedRepo, + OsFinderSearch, + Commit, + PullRequest, + ]), + ], + controllers: [OsFinderController], + providers: [ + OsFinderService, + OsFinderCacheService, + RepoHealthService, + NcfScorerService, + AiQueryBuilderService, + AiService, + ], + exports: [OsFinderService], +}) +export class OsFinderModule {} diff --git a/apps/backend/src/os-finder/os-finder.service.ts b/apps/backend/src/os-finder/os-finder.service.ts new file mode 100644 index 0000000..c103bce --- /dev/null +++ b/apps/backend/src/os-finder/os-finder.service.ts @@ -0,0 +1,711 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository as TypeOrmRepository, Not, IsNull } from 'typeorm'; +import { User } from '../users/entities/user.entity'; +import { SavedRepo } from './entities/saved-repo.entity'; +import { OsFinderSearch } from './entities/os-finder-search.entity'; +import { Commit } from '../github-sync/entities/commit.entity'; +import { PullRequest } from '../github-sync/entities/pull-request.entity'; +import { OsFinderCacheService } from './os-finder-cache.service'; +import { RepoHealthService, GithubRateLimitError } from './repo-health.service'; +import { NcfScorerService } from './ncf-scorer.service'; +import { AiQueryBuilderService } from './ai-query-builder.service'; +import { GitHubQueryBuilder } from './github-query-builder'; +import { decryptToken } from '../common/utils/crypto.util'; +import { + OsFinderFilters, + OsFinderRepoResult, + OsFinderSearchResponse, + NCFScoreBreakdown, + RepoHealthFlags, + SavedRepoStatus +} from '../../../../packages/shared-types/os-finder.types'; +import { SearchQueryDto } from './dto/search-query.dto'; +import { SaveRepoDto } from './dto/save-repo.dto'; +import { UpdateSavedRepoDto } from './dto/update-saved-repo.dto'; + +@Injectable() +export class OsFinderService { + private readonly logger = new Logger(OsFinderService.name); + private readonly GITHUB_API_BASE = 'https://api.github.com'; + + constructor( + @InjectRepository(User) + private readonly userRepository: TypeOrmRepository, + @InjectRepository(SavedRepo) + private readonly savedRepoRepository: TypeOrmRepository, + @InjectRepository(OsFinderSearch) + private readonly searchRepository: TypeOrmRepository, + @InjectRepository(Commit) + private readonly commitRepository: TypeOrmRepository, + @InjectRepository(PullRequest) + private readonly pullRequestRepository: TypeOrmRepository, + private readonly cacheService: OsFinderCacheService, + private readonly repoHealthService: RepoHealthService, + private readonly ncfScorerService: NcfScorerService, + private readonly aiQueryBuilderService: AiQueryBuilderService, + ) {} + + private async fetchGithub(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'DevPulse Backend', + }, + }); + + if (response.status === 403 || response.status === 429) { + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + if (remaining && parseInt(remaining, 10) < 50) { + const resetTime = reset ? parseInt(reset, 10) * 1000 : Date.now() + 60000; + throw new GithubRateLimitError(resetTime); + } + } + + if (!response.ok) { + throw new Error(`GitHub search failure: ${response.status} ${response.statusText} on ${url}`); + } + + return response.json(); + } + + // Step 2: Load user context (languages + avg PR score + inferred difficulty) + async loadUserContext(userId: string): Promise<{ + topLanguages: string[]; + inferredLevel: string; + avgPRScore: number; + githubToken: string; + githubUsername: string; + }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new BadRequestException('User not found'); + } + + const token = decryptToken(user.githubToken); + + // Get languages from commits + const commitLangs = await this.commitRepository + .createQueryBuilder('commit') + .innerJoinAndSelect('commit.repository', 'repository') + .where('commit.userId = :userId', { userId }) + .andWhere('repository.language IS NOT NULL') + .select('repository.language', 'language') + .addSelect('COUNT(commit.id)', 'count') + .groupBy('repository.language') + .orderBy('count', 'DESC') + .limit(3) + .getRawMany(); + + const topLanguages = commitLangs.map(cl => cl.language); + + // Inferred experience level and PR scores + const prs = await this.pullRequestRepository.find({ + where: { userId, prScore: Not(IsNull()) }, + }); + const avgPRScore = prs.length > 0 ? prs.reduce((sum, pr) => sum + pr.prScore, 0) / prs.length : 0; + + // commits history in days + const earliestCommit = await this.commitRepository.findOne({ + where: { userId }, + order: { committedAt: 'ASC' }, + }); + const commitHistoryDays = earliestCommit + ? (Date.now() - new Date(earliestCommit.committedAt).getTime()) / (1000 * 60 * 60 * 24) + : 0; + + let inferredLevel = 'beginner'; + if (commitHistoryDays >= 365 && avgPRScore > 7.5) { + inferredLevel = 'advanced'; + } else if (commitHistoryDays >= 90 && avgPRScore >= 5.0) { + inferredLevel = 'intermediate'; + } + + return { + topLanguages, + inferredLevel, + avgPRScore, + githubToken: token, + githubUsername: user.githubUsername, + }; + } + + // Main 14-step search orchestration + async search(userId: string, queryDto: SearchQueryDto): Promise { + const userCtx = await this.loadUserContext(userId); + + // Setup filter options with DTO values or defaults + const filters: OsFinderFilters = { + languages: queryDto.languages || userCtx.topLanguages, + languageMode: queryDto.languageMode || 'any_of', + difficulty: queryDto.difficulty || (userCtx.inferredLevel as any), + contributionTypes: queryDto.contributionTypes || [], + domains: queryDto.domains || [], + repoSize: queryDto.repoSize || 'any', + lastCommitDays: queryDto.lastCommitDays !== undefined ? queryDto.lastCommitDays : 90, + minOpenIssues: queryDto.minOpenIssues !== undefined ? queryDto.minOpenIssues : 3, + issueFreshDays: queryDto.issueFreshDays !== undefined ? queryDto.issueFreshDays : 60, + hasContributing: queryDto.hasContributing !== undefined ? queryDto.hasContributing : true, + hasCodeOfConduct: queryDto.hasCodeOfConduct !== undefined ? queryDto.hasCodeOfConduct : false, + licenseTypes: queryDto.licenseTypes || [], + prMergeRate: queryDto.prMergeRate !== undefined ? queryDto.prMergeRate : 30, + includeAlreadyContributed: queryDto.includeAlreadyContributed || false, + }; + + // Step 3: Check cache + const cacheHit = await this.cacheService.getSearchResults(userId, filters); + if (cacheHit) { + this.logger.log(`Serving search results from cache for user ${userId}`); + return cacheHit; + } + + // Step 4-5-12: Execute search and handle relaxation loop + let activeFilters = { ...filters }; + let githubQuery = GitHubQueryBuilder.build(activeFilters, userCtx); + let rawResults: any = null; + let filtersRelaxed: Partial | null = null; + let relaxationNote: string | null = null; + + const relaxationOrder = [ + { field: 'lastCommitDays', from: 90, to: 180, message: "last commit window relaxed to 180 days" }, + { field: 'minOpenIssues', from: 3, to: 1, message: "minimum open issues relaxed to 1" }, + { field: 'issueFreshDays', from: 60, to: 180, message: "issue freshness relaxed to 180 days" }, + { field: 'prMergeRate', from: 30, to: 10, message: "PR merge rate threshold relaxed to 10%" }, + { field: 'hasContributing', from: true, to: false, message: "CONTRIBUTING.md requirement removed" }, + ]; + + try { + rawResults = await this.fetchGithub( + `${this.GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(githubQuery)}&sort=updated&per_page=30`, + userCtx.githubToken + ); + + // Loop relaxation + if ((!rawResults || rawResults.total_count < 3) && rawResults.items) { + filtersRelaxed = {}; + const notes: string[] = []; + + for (const step of relaxationOrder) { + const field = step.field as keyof OsFinderFilters; + if (activeFilters[field] === step.from) { + (activeFilters as any)[field] = step.to; + (filtersRelaxed as any)[field] = step.to; + notes.push(step.message); + + githubQuery = GitHubQueryBuilder.build(activeFilters, userCtx); + const retryRes = await this.fetchGithub( + `${this.GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(githubQuery)}&sort=updated&per_page=30`, + userCtx.githubToken + ); + + if (retryRes && retryRes.total_count >= 3) { + rawResults = retryRes; + break; + } else if (retryRes) { + rawResults = retryRes; + } + } + } + + if (notes.length > 0) { + relaxationNote = `No repos matched all filters. We relaxed filters: ${notes.join('; ')}.`; + } + } + } catch (err) { + if (err instanceof GithubRateLimitError) { + throw err; + } + this.logger.error(`Failed github search API query: ${err instanceof Error ? err.message : String(err)}`); + rawResults = { items: [], total_count: 0 }; + } + + const items = rawResults?.items || []; + + // Load already-contributed repos + const userPrs = await this.pullRequestRepository + .createQueryBuilder('pr') + .innerJoinAndSelect('pr.repository', 'repository') + .where('pr.userId = :userId', { userId }) + .getMany(); + const userContributed = new Set(userPrs.map(pr => pr.repository?.fullName).filter(Boolean)); + + // Load saved repos watchlist to flag isSaved + const saved = await this.savedRepoRepository.find({ where: { userId } }); + const savedRepoIds = new Set(saved.map(s => Number(s.githubRepoId))); + + // Step 6: Parallel health check processing (Staggered Promise Pool) + const processedResults = await this.processHealthAndNcfInParallel( + items, + userCtx.githubToken, + userCtx.topLanguages, + userContributed, + savedRepoIds + ); + + // Apply filters and exclusions + let results = processedResults; + + // Step 9: Already contributed exclusion + if (!filters.includeAlreadyContributed) { + results = results.filter(r => !r.alreadyContrib); + } + + // Step 10: Overwhelming repos guard (Star > 100k) + // If not advanced+large, push to bottom, or just split + const majorRepos = results.filter(r => r.stars > 100000); + const regularRepos = results.filter(r => r.stars <= 100000); + + // Sort regular repos: primary: (ncfScore * langMatchScore), secondary: lastCommitAt DESC + regularRepos.sort((a, b) => { + const scoreA = (a.ncfScore?.total || 0) * a.langMatchScore; + const scoreB = (b.ncfScore?.total || 0) * b.langMatchScore; + if (scoreA !== scoreB) { + return scoreB - scoreA; + } + return new Date(b.lastCommitAt).getTime() - new Date(a.lastCommitAt).getTime(); + }); + + // Concat regular then major repos + const finalResults = [...regularRepos, ...majorRepos]; + + const response: OsFinderSearchResponse = { + results: finalResults, + total: rawResults.total_count || 0, + page: queryDto.page || 1, + filtersApplied: filters, + filtersRelaxed, + relaxationNote, + aiModeUsed: false, + }; + + // Step 13: Cache results + await this.cacheService.setSearchResults(userId, filters, response); + + // Step 14: Log search asynchronously + this.searchRepository.save({ + userId, + queryText: null, + filtersApplied: filters, + resultCount: finalResults.length, + aiQueryUsed: false, + githubQuery, + }).catch(err => this.logger.error(`Failed to log search query: ${err.message}`)); + + return response; + } + + // AI Search orchestrator + async searchAi(userId: string, body: { query: string }): Promise { + const userCtx = await this.loadUserContext(userId); + + const { filters, keywords, fallbackUsed } = await this.aiQueryBuilderService.buildFilters( + body.query, + userCtx + ); + + const cacheHit = await this.cacheService.getSearchResults(userId, filters); + if (cacheHit) { + cacheHit.aiModeUsed = true; + if (fallbackUsed) { + cacheHit.relaxationNote = "AI query builder unavailable. Using keyword matching instead."; + } + return cacheHit; + } + + let activeFilters = { ...filters }; + let githubQuery = GitHubQueryBuilder.build(activeFilters, userCtx, keywords); + let rawResults: any = null; + let filtersRelaxed: Partial | null = null; + let relaxationNote: string | null = fallbackUsed ? "AI query builder unavailable. Using keyword matching instead." : null; + + try { + rawResults = await this.fetchGithub( + `${this.GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(githubQuery)}&sort=updated&per_page=30`, + userCtx.githubToken + ); + } catch (err) { + this.logger.error(`Failed github search AI query: ${err instanceof Error ? err.message : String(err)}`); + rawResults = { items: [], total_count: 0 }; + } + + const items = rawResults?.items || []; + + // Load already-contributed repos + const userPrs = await this.pullRequestRepository + .createQueryBuilder('pr') + .innerJoinAndSelect('pr.repository', 'repository') + .where('pr.userId = :userId', { userId }) + .getMany(); + const userContributed = new Set(userPrs.map(pr => pr.repository?.fullName).filter(Boolean)); + + // Load saved repos + const saved = await this.savedRepoRepository.find({ where: { userId } }); + const savedRepoIds = new Set(saved.map(s => Number(s.githubRepoId))); + + // Staggered promise health + NCF + const processedResults = await this.processHealthAndNcfInParallel( + items, + userCtx.githubToken, + userCtx.topLanguages, + userContributed, + savedRepoIds + ); + + let results = processedResults; + if (!filters.includeAlreadyContributed) { + results = results.filter(r => !r.alreadyContrib); + } + + const majorRepos = results.filter(r => r.stars > 100000); + const regularRepos = results.filter(r => r.stars <= 100000); + + regularRepos.sort((a, b) => { + const scoreA = (a.ncfScore?.total || 0) * a.langMatchScore; + const scoreB = (b.ncfScore?.total || 0) * b.langMatchScore; + if (scoreA !== scoreB) { + return scoreB - scoreA; + } + return new Date(b.lastCommitAt).getTime() - new Date(a.lastCommitAt).getTime(); + }); + + const finalResults = [...regularRepos, ...majorRepos]; + + const response: OsFinderSearchResponse = { + results: finalResults, + total: rawResults.total_count || 0, + page: 1, + filtersApplied: filters, + filtersRelaxed: null, + relaxationNote, + aiModeUsed: true, + }; + + await this.cacheService.setSearchResults(userId, filters, response); + + this.searchRepository.save({ + userId, + queryText: body.query, + filtersApplied: filters, + resultCount: finalResults.length, + aiQueryUsed: true, + githubQuery, + }).catch(err => this.logger.error(`Failed to log search query: ${err.message}`)); + + return response; + } + + // Staggered parallel health + NCF processor (staggered queue) + private async processHealthAndNcfInParallel( + items: any[], + token: string, + topLanguages: string[], + userContributed: Set, + savedRepoIds: Set + ): Promise { + const results: OsFinderRepoResult[] = []; + const activePromises: Promise[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + // Concurrency limit = 10 + while (activePromises.length >= 10) { + await Promise.race(activePromises); + } + + // Stagger start: 200ms wait + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + + const promise = (async () => { + const owner = item.owner.login; + const repo = item.name; + + try { + const healthFlags = await this.repoHealthService.computeRepoHealth(owner, repo, token, item); + const ncfScore = await this.ncfScorerService.computeNCFScore(owner, repo, token, item); + + // Language match calculation (weights: Rank 1: 1.0, Rank 2: 0.6, Rank 3: 0.3) + let langMatchScore = 0.0; + if (item.language) { + const index = topLanguages.indexOf(item.language); + if (index === 0) langMatchScore = 1.0; + else if (index === 1) langMatchScore = 0.6; + else if (index === 2) langMatchScore = 0.3; + } + + const alreadyContrib = userContributed.has(item.full_name); + const isSaved = savedRepoIds.has(Number(item.id)); + + return { + githubRepoId: Number(item.id), + owner, + name: repo, + fullName: item.full_name, + description: item.description || null, + language: item.language || null, + stars: item.stargazers_count || 0, + forks: item.forks_count || 0, + openIssues: item.open_issues_count || 0, + lastCommitAt: item.pushed_at || item.updated_at, + licenseType: item.license?.name || item.license?.spdx_id || null, + htmlUrl: item.html_url, + ncfScore, + langMatchScore, + openLabels: ['good first issue', 'help wanted'], // labels we target + healthFlags, + isSaved, + alreadyContrib, + }; + } catch (err) { + this.logger.warn(`Staggered health check failed for ${item.full_name}: ${err instanceof Error ? err.message : String(err)}`); + // Default fallback results if rate limited + return { + githubRepoId: Number(item.id), + owner, + name: repo, + fullName: item.full_name, + description: item.description || null, + language: item.language || null, + stars: item.stargazers_count || 0, + forks: item.forks_count || 0, + openIssues: item.open_issues_count || 0, + lastCommitAt: item.pushed_at || item.updated_at, + licenseType: item.license?.name || item.license?.spdx_id || null, + htmlUrl: item.html_url, + ncfScore: { + total: 0.0, // Health check pending + goodFirstIssue: 0, + helpWanted: 0, + contributingFile: 0, + issueResponseTime: 0, + newContribPR: 0, + readmeQuality: 0, + codeOfConduct: 0, + prMergeRate: 0, + }, + langMatchScore: 0.0, + openLabels: [], + healthFlags: { + isArchived: false, + isStale: false, + noContributing: false, + noReadme: false, + lowPRMergeRate: false, + forkHeavy: false, + noExternalContribs: false, + lowIssueEngagement: false, + slowMaintainerResp: false, + }, + isSaved: savedRepoIds.has(Number(item.id)), + alreadyContrib: userContributed.has(item.full_name), + }; + } + })(); + + activePromises.push(promise); + promise.then(res => { + results.push(res); + const idx = activePromises.indexOf(promise); + if (idx > -1) { + activePromises.splice(idx, 1); + } + }); + } + + await Promise.all(activePromises); + return results; + } + + // GET /os-finder/repo/:owner/:repo + async getRepoDetail(userId: string, owner: string, repo: string): Promise { + const userCtx = await this.loadUserContext(userId); + const token = userCtx.githubToken; + + const detailsUrl = `${this.GITHUB_API_BASE}/repos/${owner}/${repo}`; + const repoDetails = await this.fetchGithub(detailsUrl, token); + if (!repoDetails) { + throw new NotFoundException(`GitHub Repository ${owner}/${repo} not found`); + } + + const healthFlags = await this.repoHealthService.computeRepoHealth(owner, repo, token, repoDetails); + const ncfScore = await this.ncfScorerService.computeNCFScore(owner, repo, token, repoDetails); + + // Fetch top 5 contributors + const contributors = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/contributors?per_page=5`, + token + ).catch(() => []); + + // Fetch last 5 merged PRs + const recentPRs = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?state=closed&per_page=15`, + token + ).catch(() => []); + const mergedPRs = Array.isArray(recentPRs) ? recentPRs.filter(pr => pr.merged_at).slice(0, 5) : []; + + // Check if saved + const saved = await this.savedRepoRepository.findOne({ + where: { userId, githubRepoId: repoDetails.id }, + }); + + return { + githubRepoId: repoDetails.id, + owner: repoDetails.owner?.login, + name: repoDetails.name, + fullName: repoDetails.full_name, + description: repoDetails.description, + language: repoDetails.language, + stars: repoDetails.stargazers_count, + forks: repoDetails.forks_count, + openIssues: repoDetails.open_issues_count, + lastCommitAt: repoDetails.pushed_at || repoDetails.updated_at, + licenseType: repoDetails.license?.name || repoDetails.license?.spdx_id || null, + htmlUrl: repoDetails.html_url, + ncfScore, + healthFlags, + contributors: Array.isArray(contributors) ? contributors.map(c => ({ login: c.login, avatarUrl: c.avatar_url, contributions: c.contributions })) : [], + recentPRs: mergedPRs.map(pr => ({ id: pr.id, title: pr.title, url: pr.html_url, author: pr.user?.login, authorAvatar: pr.user?.avatar_url, mergedAt: pr.merged_at })), + savedId: saved?.id || null, + notes: saved?.notes || null, + status: saved?.status || null, + }; + } + + // GET /os-finder/repo/:owner/:repo/issues (beginner open issues list) + async getRepoIssues(userId: string, owner: string, repo: string): Promise { + const userCtx = await this.loadUserContext(userId); + // Fetch labels combined + const labels = ['good first issue', 'help wanted']; + + // Check cache + const cacheHit = await this.cacheService.getRepoIssues(owner, repo, labels); + if (cacheHit) { + return cacheHit; + } + + const url = `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/issues?state=open&per_page=30`; + const response = await this.fetchGithub(url, userCtx.githubToken); + + if (Array.isArray(response)) { + // Filter out PRs, and retain ones matching labels + const issues = response.filter(item => { + if (item.pull_request) return false; + const itemLabels = item.labels?.map((l: any) => l.name.toLowerCase()) || []; + return itemLabels.some((l: string) => labels.includes(l)); + }).map(issue => ({ + id: issue.id, + number: issue.number, + title: issue.title, + url: issue.html_url, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + commentsCount: issue.comments, + labels: issue.labels?.map((l: any) => l.name) || [], + })); + + await this.cacheService.setRepoIssues(owner, repo, labels, issues); + return issues; + } + + return []; + } + + // POST /os-finder/saved (watchlist) + async saveRepo(userId: string, dto: SaveRepoDto): Promise { + let saved = await this.savedRepoRepository.findOne({ + where: { userId, githubRepoId: dto.githubRepoId }, + }); + + if (!saved) { + saved = this.savedRepoRepository.create({ + userId, + githubRepoId: dto.githubRepoId, + owner: dto.owner, + name: dto.name, + fullName: dto.fullName, + description: dto.description || null, + language: dto.language || null, + stars: dto.stars || 0, + forks: dto.forks || 0, + openIssues: dto.openIssues || 0, + ncfScore: dto.ncfScore || null, + langMatchScore: dto.langMatchScore || null, + lastCommitAt: dto.lastCommitAt ? new Date(dto.lastCommitAt) : null, + hasContributing: dto.hasContributing || false, + licenseType: dto.licenseType || null, + htmlUrl: dto.htmlUrl, + notes: dto.notes || null, + status: dto.status || 'saved', + }); + } else { + Object.assign(saved, { + notes: dto.notes !== undefined ? dto.notes : saved.notes, + status: dto.status || saved.status, + }); + } + + const res = await this.savedRepoRepository.save(saved); + await this.cacheService.invalidateSavedRepos(userId); + return res; + } + + // GET /os-finder/saved (watchlist) + async getSavedRepos(userId: string): Promise { + // Check cache + const cacheHit = await this.cacheService.getSavedRepos(userId); + if (cacheHit) { + return cacheHit; + } + + const list = await this.savedRepoRepository.find({ + where: { userId }, + order: { savedAt: 'DESC' }, + }); + + await this.cacheService.setSavedRepos(userId, list); + return list; + } + + // PATCH /os-finder/saved/:id + async updateSavedRepo(userId: string, id: string, dto: UpdateSavedRepoDto): Promise { + const saved = await this.savedRepoRepository.findOne({ where: { id, userId } }); + if (!saved) { + throw new NotFoundException(`Saved repo item ${id} not found`); + } + + if (dto.notes !== undefined) { + saved.notes = dto.notes; + } + if (dto.status !== undefined) { + saved.status = dto.status; + } + + const res = await this.savedRepoRepository.save(saved); + await this.cacheService.invalidateSavedRepos(userId); + return res; + } + + // DELETE /os-finder/saved/:id + async deleteSavedRepo(userId: string, id: string): Promise { + const saved = await this.savedRepoRepository.findOne({ where: { id, userId } }); + if (!saved) { + throw new NotFoundException(`Saved repo item ${id} not found`); + } + + await this.savedRepoRepository.remove(saved); + await this.cacheService.invalidateSavedRepos(userId); + } + + // GET /os-finder/history + async getSearchHistory(userId: string): Promise { + return this.searchRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 10, + }); + } +} diff --git a/apps/backend/src/os-finder/repo-health.service.ts b/apps/backend/src/os-finder/repo-health.service.ts new file mode 100644 index 0000000..2b2b6e3 --- /dev/null +++ b/apps/backend/src/os-finder/repo-health.service.ts @@ -0,0 +1,181 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OsFinderCacheService } from './os-finder-cache.service'; +import { RepoHealthFlags } from '../../../../packages/shared-types/os-finder.types'; + +export class GithubRateLimitError extends Error { + constructor(public readonly resetTime: number) { + super('GitHub API Rate Limit Threshold reached'); + this.name = 'GithubRateLimitError'; + } +} + +@Injectable() +export class RepoHealthService { + private readonly logger = new Logger(RepoHealthService.name); + private readonly GITHUB_API_BASE = 'https://api.github.com'; + + constructor(private readonly cacheService: OsFinderCacheService) {} + + private async fetchGithub(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'DevPulse Backend', + }, + }); + + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + + if (remaining) { + const remainingVal = parseInt(remaining, 10); + if (remainingVal < 50) { + const resetTime = reset ? parseInt(reset, 10) * 1000 : Date.now() + 60000; + this.logger.warn(`GitHub API Rate limit low: ${remainingVal} remaining. Reset in ${Math.round((resetTime - Date.now()) / 1000)}s`); + throw new GithubRateLimitError(resetTime); + } + } + + if (!response.ok) { + if (response.status === 404) { + return null; + } + if (response.status === 202) { + // Contributor stats are compiling, return 202 + return { status: 202 }; + } + throw new Error(`GitHub API call failed: ${response.status} ${response.statusText} on ${url}`); + } + + return response.json(); + } + + async computeRepoHealth( + owner: string, + repo: string, + token: string, + repoDetails: any // passed in to save API calls since we get it from Search API + ): Promise { + const cacheHit = await this.cacheService.getRepoHealth(owner, repo); + if (cacheHit) { + return cacheHit; + } + + const healthFlags: RepoHealthFlags = { + isArchived: false, + isStale: false, + noContributing: false, + noReadme: false, + lowPRMergeRate: false, + forkHeavy: false, + noExternalContribs: false, + lowIssueEngagement: false, + slowMaintainerResp: false, + }; + + try { + // 1. isArchived (from search details) + healthFlags.isArchived = !!repoDetails?.archived; + + // 2. isStale: pushed_at > 180 days ago + const pushedTime = new Date(repoDetails?.pushed_at || repoDetails?.updated_at || Date.now()).getTime(); + const staleTime = 180 * 24 * 60 * 60 * 1000; + healthFlags.isStale = (Date.now() - pushedTime) > staleTime; + + // 3. forkHeavy: forks > stars * 1.5 AND stars < 500 + const stars = repoDetails?.stargazers_count || repoDetails?.stars || 0; + const forks = repoDetails?.forks_count || repoDetails?.forks || 0; + healthFlags.forkHeavy = forks > (stars * 1.5) && stars < 500; + + // Fetch Community Profile (CONTRIBUTING, README, CoC) + const communityProfile = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/community/profile`, + token + ); + + if (communityProfile) { + healthFlags.noContributing = !communityProfile.files?.contributing; + healthFlags.noReadme = !communityProfile.files?.readme; + } else { + // Fallback if not found + healthFlags.noContributing = true; + healthFlags.noReadme = true; + } + + // Fetch recent closed PRs to compute lowPRMergeRate + const closedPRStatsCache = await this.cacheService.getPRStats(owner, repo); + let prStats = closedPRStatsCache; + + if (!prStats) { + const closedPRs = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?state=closed&per_page=30`, + token + ); + if (Array.isArray(closedPRs) && closedPRs.length > 0) { + const mergedCount = closedPRs.filter(pr => pr.merged_at).length; + const mergeRate = (mergedCount / closedPRs.length) * 100; + prStats = { mergeRate }; + await this.cacheService.setPRStats(owner, repo, prStats); + } else { + prStats = { mergeRate: 100 }; // assume fine if no PRs + } + } + + healthFlags.lowPRMergeRate = prStats.mergeRate < 20; + + // Fetch contributor stats to compute noExternalContribs + const contribStats = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/stats/contributors`, + token + ); + + if (Array.isArray(contribStats) && contribStats.length > 0) { + const sortedContribs = contribStats + .map((c: any) => c.total || 0) + .sort((a: number, b: number) => b - a); + + const totalCommits = sortedContribs.reduce((sum: number, commits: number) => sum + commits, 0); + if (totalCommits > 0) { + const top3Commits = sortedContribs.slice(0, 3).reduce((sum: number, commits: number) => sum + commits, 0); + healthFlags.noExternalContribs = (top3Commits / totalCommits) > 0.95; + } + } + + // Fetch closed issues to compute lowIssueEngagement and slowMaintainerResp + const closedIssues = await this.fetchGithub( + `${this.GITHUB_API_BASE}/repos/${owner}/${repo}/issues?state=closed&per_page=20`, + token + ); + + if (Array.isArray(closedIssues) && closedIssues.length > 0) { + // filter out PRs + const cleanIssues = closedIssues.filter(item => !item.pull_request); + if (cleanIssues.length > 0) { + const zeroCommentClosed = cleanIssues.filter(issue => (issue.comments || 0) === 0).length; + healthFlags.lowIssueEngagement = (zeroCommentClosed / cleanIssues.length) > 0.60; + + // Compute average close time + let totalCloseDays = 0; + cleanIssues.forEach(issue => { + const created = new Date(issue.created_at).getTime(); + const closed = new Date(issue.closed_at || Date.now()).getTime(); + totalCloseDays += (closed - created) / (1000 * 60 * 60 * 24); + }); + const avgCloseDays = totalCloseDays / cleanIssues.length; + healthFlags.slowMaintainerResp = avgCloseDays > 30; + } + } + + // Save to cache + await this.cacheService.setRepoHealth(owner, repo, healthFlags); + } catch (error) { + if (error instanceof GithubRateLimitError) { + throw error; + } + this.logger.error(`Error computing health for ${owner}/${repo}: ${error instanceof Error ? error.message : String(error)}`); + } + + return healthFlags; + } +} diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index a676021..c9dac4e 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -2,10 +2,11 @@ FROM node:18-alpine AS builder WORKDIR /app -COPY package*.json ./ +COPY apps/frontend/package*.json ./ RUN npm ci --legacy-peer-deps -COPY . . +COPY apps/frontend/ . +COPY packages/ /packages/ ARG BACKEND_URL ARG NEXT_PUBLIC_BACKEND_URL ENV BACKEND_URL=${BACKEND_URL} diff --git a/apps/frontend/components/dashboard/DashboardShell.tsx b/apps/frontend/components/dashboard/DashboardShell.tsx index d78d57b..2068405 100644 --- a/apps/frontend/components/dashboard/DashboardShell.tsx +++ b/apps/frontend/components/dashboard/DashboardShell.tsx @@ -3,13 +3,14 @@ import { motion } from "framer-motion"; import { ReactNode } from "react"; import { Navbar } from "../Navbar"; -type RouteKey = "dashboard" | "commits" | "prs" | "digest" | "settings"; +type RouteKey = "dashboard" | "commits" | "prs" | "digest" | "settings" | "os-finder"; const tabs: Array<{ key: RouteKey; label: string; href: string }> = [ { key: "dashboard", label: "Overview", href: "/dashboard" }, { key: "commits", label: "Commits", href: "/dashboard/commits" }, { key: "prs", label: "PRs", href: "/dashboard/prs" }, { key: "digest", label: "Digest", href: "/dashboard/digest" }, + { key: "os-finder", label: "OS Finder", href: "/dashboard/os-finder" }, { key: "settings", label: "Settings", href: "/dashboard/settings" }, ]; diff --git a/apps/frontend/next.config.js b/apps/frontend/next.config.js index ffb3c59..8c3b860 100644 --- a/apps/frontend/next.config.js +++ b/apps/frontend/next.config.js @@ -36,6 +36,10 @@ const nextConfig = { source: '/digests/:path*', destination: `${backendUrl}/digests/:path*`, }, + { + source: '/os-finder/:path*', + destination: `${backendUrl}/os-finder/:path*`, + }, ]; }, }; diff --git a/apps/frontend/pages/dashboard/os-finder/[owner]/[repo].tsx b/apps/frontend/pages/dashboard/os-finder/[owner]/[repo].tsx new file mode 100644 index 0000000..0e9fff1 --- /dev/null +++ b/apps/frontend/pages/dashboard/os-finder/[owner]/[repo].tsx @@ -0,0 +1,644 @@ +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { motion } from "framer-motion"; +import { DashboardShell } from "../../../../components/dashboard/DashboardShell"; +import { useAuth } from "../../../../context/AuthContext"; +import { useDashboardData } from "../../../../hooks/useDashboardData"; + +type RepoDetailData = { + githubRepoId: number; + owner: string; + name: string; + fullName: string; + description: string | null; + language: string | null; + stars: number; + forks: number; + openIssues: number; + lastCommitAt: string; + licenseType: string | null; + htmlUrl: string; + ncfScore: { + total: number; + goodFirstIssue: number; + helpWanted: number; + contributingFile: number; + issueResponseTime: number; + newContribPR: number; + readmeQuality: number; + codeOfConduct: number; + prMergeRate: number; + }; + healthFlags: { + isArchived: boolean; + isStale: boolean; + noContributing: boolean; + noReadme: boolean; + lowPRMergeRate: boolean; + forkHeavy: boolean; + noExternalContribs: boolean; + lowIssueEngagement: boolean; + slowMaintainerResp: boolean; + }; + contributors: Array<{ login: string; avatarUrl: string; contributions: number }>; + recentPRs: Array<{ id: number; title: string; url: string; author: string; authorAvatar: string; mergedAt: string }>; + savedId: string | null; + notes: string | null; + status: "saved" | "contributed" | "skipped" | null; +}; + +export default function OsFinderRepoDetailPage() { + const router = useRouter(); + const { owner, repo } = router.query as { owner?: string; repo?: string }; + const { fetchWithAuth } = useAuth(); + const { user, loading: dashboardLoading } = useDashboardData(); + + // Detail & Issues state + const [detail, setDetail] = useState(null); + const [issues, setIssues] = useState([]); + const [loadingDetail, setLoadingDetail] = useState(true); + const [loadingIssues, setLoadingIssues] = useState(true); + + // Watchlist Local settings State + const [isSaved, setIsSaved] = useState(false); + const [notes, setNotes] = useState(""); + const [status, setStatus] = useState<"saved" | "contributed" | "skipped">("saved"); + const [updatingWatchlist, setUpdatingWatchlist] = useState(false); + + useEffect(() => { + if (!dashboardLoading && !user) { + router.replace("/"); + } + }, [dashboardLoading, router, user]); + + useEffect(() => { + if (user && owner && repo) { + loadDetails(); + loadIssues(); + } + }, [user, owner, repo]); + + const loadDetails = async () => { + setLoadingDetail(true); + try { + const res = await fetchWithAuth(`/os-finder/repo/${owner}/${repo}`); + if (res.ok) { + const data = await res.json(); + setDetail(data); + setIsSaved(!!data.savedId); + setNotes(data.notes || ""); + setStatus(data.status || "saved"); + } else { + console.error("Failed to load repo details"); + } + } catch (err) { + console.error(err); + } finally { + setLoadingDetail(false); + } + }; + + const loadIssues = async () => { + setLoadingIssues(true); + try { + const res = await fetchWithAuth(`/os-finder/repo/${owner}/${repo}/issues`); + if (res.ok) { + const data = await res.json(); + setIssues(data || []); + } + } catch (err) { + console.error("Failed to load repo issues:", err); + } finally { + setLoadingIssues(false); + } + }; + + const handleWatchlistSave = async () => { + if (!detail) return; + setUpdatingWatchlist(true); + + try { + if (isSaved) { + // Update details (status + notes) + const savedListRes = await fetchWithAuth("/os-finder/saved"); + if (savedListRes.ok) { + const savedList = await savedListRes.json(); + const match = savedList.find((s: any) => Number(s.githubRepoId) === detail.githubRepoId); + if (match) { + const updRes = await fetchWithAuth(`/os-finder/saved/${match.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ notes, status }), + }); + if (updRes.ok) { + alert("Watchlist updated successfully."); + } + } + } + } else { + // Save new item + const saveDto = { + githubRepoId: detail.githubRepoId, + owner: detail.owner, + name: detail.name, + fullName: detail.fullName, + description: detail.description, + language: detail.language, + stars: detail.stars, + forks: detail.forks, + openIssues: detail.openIssues, + ncfScore: detail.ncfScore, + langMatchScore: 1.0, + lastCommitAt: detail.lastCommitAt, + hasContributing: !detail.healthFlags.noContributing, + licenseType: detail.licenseType, + htmlUrl: detail.htmlUrl, + notes, + status, + }; + + const saveRes = await fetchWithAuth("/os-finder/saved", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(saveDto), + }); + + if (saveRes.ok) { + setIsSaved(true); + alert("Repository added to Watchlist."); + } + } + } catch (err) { + console.error(err); + } finally { + setUpdatingWatchlist(false); + } + }; + + const handleWatchlistRemove = async () => { + if (!detail) return; + if (!confirm("Are you sure you want to remove this repo from your watchlist?")) return; + + setUpdatingWatchlist(true); + try { + const savedListRes = await fetchWithAuth("/os-finder/saved"); + if (savedListRes.ok) { + const savedList = await savedListRes.json(); + const match = savedList.find((s: any) => Number(s.githubRepoId) === detail.githubRepoId); + if (match) { + const delRes = await fetchWithAuth(`/os-finder/saved/${match.id}`, { method: "DELETE" }); + if (delRes.ok) { + setIsSaved(false); + setNotes(""); + alert("Removed from Watchlist."); + } + } + } + } catch (err) { + console.error(err); + } finally { + setUpdatingWatchlist(false); + } + }; + + const getNcfColorClass = (score: number) => { + if (score >= 7.5) return "text-emerald-400 bg-emerald-950/40 border-emerald-900/50"; + if (score >= 5.0) return "text-amber-400 bg-amber-950/40 border-amber-900/50"; + return "text-sky-400 bg-sky-950/40 border-sky-900/50"; + }; + + const getNcfBarColor = (score: number) => { + if (score >= 7.5) return "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.3)]"; + if (score >= 5.0) return "bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.3)]"; + return "bg-sky-500 shadow-[0_0_8px_rgba(59,130,246,0.3)]"; + }; + + if (dashboardLoading || !user) return null; + + return ( + +
+ + + + + Back to Discovery Engine + + + {detail && ( + + View on GitHub + + + + + )} +
+ + {loadingDetail || !detail ? ( + /* Loading skeleton */ +
+
+
+
+
+
+
+
+
+ ) : ( +
+ {/* Main info, NCF bars and health flags */} +
+ {/* Header info card */} +
+
+

{detail.name}

+ {detail.language && ( + {detail.language} + )} + {detail.licenseType && ( + + {detail.licenseType} + + )} +
+ +

+ {detail.description || "No description provided."} +

+ + {/* Counter badges */} +
+
+ {detail.stars.toLocaleString()} + Stars +
+
+ {detail.forks.toLocaleString()} + Forks +
+
+ {detail.openIssues.toLocaleString()} + Open Issues +
+
+
+ + {/* NCF Score Breakdown Card */} +
+
+
+

New Contributor Friendliness (NCF)

+

8 metrics representing maintainer responsiveness and code accessibility.

+
+ + {Number(detail.ncfScore.total).toFixed(1)} / 10 + +
+ + {/* NCF Bars grid */} +
+ {/* Metric Item: Good First Issues */} +
+
+ Good First Issues Label (25%) + {detail.ncfScore.goodFirstIssue} / 2.5 +
+
+
+
+
+ + {/* Metric Item: Help Wanted */} +
+
+ Help Wanted Label (10%) + {detail.ncfScore.helpWanted} / 1.0 +
+
+
+
+
+ + {/* Metric Item: Contributing Guide */} +
+
+ CONTRIBUTING.md file present (15%) + {detail.ncfScore.contributingFile} / 1.5 +
+
+
+
+
+ + {/* Metric Item: Issue Response Time */} +
+
+ Issue Closure Responsiveness (20%) + {detail.ncfScore.issueResponseTime} / 2.0 +
+
+
+
+
+ + {/* Metric Item: New Contributor PR */} +
+
+ Recent Merged External PRs (15%) + {detail.ncfScore.newContribPR} / 1.5 +
+
+
+
+
+ + {/* Metric Item: Code of Conduct */} +
+
+ Code of Conduct present (5%) + {detail.ncfScore.codeOfConduct} / 0.5 +
+
+
+
+
+ + {/* Metric Item: Readme Quality */} +
+
+ README quality & detail (5%) + {detail.ncfScore.readmeQuality} / 0.5 +
+
+
+
+
+ + {/* Metric Item: PR Merge Rate */} +
+
+ Pull Request Merge Rate (5%) + {detail.ncfScore.prMergeRate} / 0.5 +
+
+
+
+
+
+
+ + {/* Beginner Issues list section */} +
+

Open Onboarding Issues

+

Active tickets marked with good-first-issue or help-wanted labels.

+ + {loadingIssues ? ( +
+
+
+
+ ) : issues.length === 0 ? ( +
+ No issues matching new contributor labels were found. +
+ ) : ( + + )} +
+
+ + {/* Right sidebar: Watchlist panel, Community profile details, PR lists */} +
+ {/* Watchlist Manager Panel */} +
+

Watchlist Settings

+ +
+
+ Status + +
+ +
+ Contribution Notes +