From 37ef3a6d21dc0e1cecbfcf5f28a0ed440d8ae579 Mon Sep 17 00:00:00 2001 From: aditya pichikala Date: Fri, 29 May 2026 22:52:54 +0530 Subject: [PATCH 1/2] feat: add Agentic AI Contribution Recommender Engine (Issue #611) - Add useContributionRecommender hook with 3-step agentic pipeline: * Step 1 (Skill Profiler): fetches user repos, detects top languages weighted by recency and stars, infers domain (frontend/backend/devops) * Step 2 (Issue Scout): searches GitHub for open issues tagged 'good first issue' or 'help wanted' matching top languages, using Promise.allSettled for non-blocking async parallelism * Step 3 (Relevance Ranker): scores issues by language rank, label quality, repo star count, and recency; returns top 3 - Add ContributionRecommender.tsx widget with premium glassmorphism UI: * Gradient accent bar and indigo/sky branded header with Sparkles icon * Skill profile badge row showing detected languages + activity level * Loading state with animated 3-step progress bar and shimmer skeleton * Framer-motion stagger animations on recommendation cards * Each card: repo name, star count, issue title, 'Why this matches you' reasoning box, labels, difficulty badge (Beginner/Intermediate), CTA * Error/empty states with retry button * Session-level caching via useRef to avoid redundant API calls * Refresh button to force re-run - Integrate widget into Tracker.tsx below DailyActivityStatus - Add @keyframes recommender-pulse and recommender-shimmer to index.css - Document future GITHUB_SERVER_TOKEN env var in backend/.env.example Closes #611 --- src/components/ContributionRecommender.tsx | 686 +++++++++++++++++++++ src/hooks/useContributionRecommender.ts | 420 +++++++++++++ src/index.css | 250 +++++--- src/pages/Tracker/Tracker.tsx | 324 +++++++--- 4 files changed, 1503 insertions(+), 177 deletions(-) create mode 100644 src/components/ContributionRecommender.tsx create mode 100644 src/hooks/useContributionRecommender.ts diff --git a/src/components/ContributionRecommender.tsx b/src/components/ContributionRecommender.tsx new file mode 100644 index 00000000..f8d4d6c6 --- /dev/null +++ b/src/components/ContributionRecommender.tsx @@ -0,0 +1,686 @@ +import React, { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Paper, + Typography, + Chip, + Link, + CircularProgress, + Alert, + Tooltip, + IconButton, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + Sparkles, + RefreshCw, + ExternalLink, + Star, + BrainCircuit, + Search, + Trophy, + Zap, +} from 'lucide-react'; +import { Octokit } from '@octokit/core'; +import { + useContributionRecommender, + type Recommendation, + type SkillProfile, +} from '../hooks/useContributionRecommender'; + +// ─── Language color map (mirrors Tracker.tsx) ─────────────────────────────── + +const LANGUAGE_COLORS: Record = { + JavaScript: '#f1e05a', + TypeScript: '#3178c6', + Python: '#3572A5', + Java: '#b07219', + HTML: '#e34c26', + CSS: '#563d7c', + C: '#555555', + 'C++': '#f34b7d', + 'C#': '#178600', + PHP: '#4F5D95', + Ruby: '#701516', + Go: '#00ADD8', + Rust: '#dea584', + Kotlin: '#A97BFF', + Swift: '#F05138', + Shell: '#89e051', + Vue: '#41b883', + Dart: '#00B4AB', +}; + +const getLangColor = (lang: string) => LANGUAGE_COLORS[lang] ?? '#9ca3af'; + +// ─── Agent step labels ─────────────────────────────────────────────────────── + +const AGENT_STEPS = [ + { icon: BrainCircuit, label: 'Building skill profile…' }, + { icon: Search, label: 'Scouting open issues…' }, + { icon: Trophy, label: 'Ranking by relevance…' }, +]; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +const SkillBadge: React.FC<{ lang: string }> = ({ lang }) => ( + + } + sx={{ + fontSize: '0.72rem', + fontWeight: 600, + height: 24, + border: '1px solid', + borderColor: 'divider', + bgcolor: 'background.paper', + '& .MuiChip-label': { pr: 1 }, + }} + /> +); + +const DifficultyBadge: React.FC<{ difficulty: 'Beginner' | 'Intermediate' }> = ({ + difficulty, +}) => ( + +); + +interface RecommendationCardProps { + rec: Recommendation; + index: number; +} + +const RecommendationCard: React.FC = ({ rec, index }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const accentColors = ['#6366f1', '#0ea5e9', '#10b981']; + const accent = accentColors[index % accentColors.length]; + + return ( + + + {/* Repo header */} + + + + + {rec.repoName} + + + + + + {rec.repoStars >= 1000 + ? `${(rec.repoStars / 1000).toFixed(1)}k` + : rec.repoStars} + + + + + {/* Issue title */} + + #{rec.issueNumber} {rec.issueTitle} + + + {/* Why it matches */} + + + + {rec.matchReason} + + + + {/* Labels + Difficulty + CTA */} + + {rec.labels.slice(0, 3).map((label) => ( + + ))} + + + + View Issue + + + + + + + ); +}; + +// ─── Loading skeleton ───────────────────────────────────────────────────────── + +const LoadingSkeleton: React.FC<{ agentStep: 0 | 1 | 2 | 3 }> = ({ agentStep }) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const stepIndex = Math.max(0, agentStep - 1); + const StepIcon = agentStep > 0 ? AGENT_STEPS[stepIndex]?.icon : null; + const stepLabel = agentStep > 0 ? AGENT_STEPS[stepIndex]?.label : 'Initializing…'; + + return ( + + {/* Agent step indicator */} + + + {StepIcon && } + + {stepLabel} + + + {[1, 2, 3].map((step) => ( + + ))} + + + + {/* Shimmer cards */} + {[0, 1, 2].map((i) => ( + + + + + + + + + + {[70, 90, 55].map((w) => ( + + ))} + + + ))} + + ); +}; + +// ─── Skill Profile Header ───────────────────────────────────────────────────── + +const SkillProfileRow: React.FC<{ profile: SkillProfile }> = ({ profile }) => { + const ACTIVITY_COLORS: Record = { + high: '#2ea44f', + medium: '#b08800', + low: '#cf222e', + }; + + return ( + + + + Your skills: + + {profile.topLanguages.map((lang) => ( + + ))} + + + + {profile.activityLevel} activity + + + + + ); +}; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +interface ContributionRecommenderProps { + username: string; + getOctokit: () => Octokit | null; +} + +const ContributionRecommender: React.FC = ({ + username, + getOctokit, +}) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + const hasRunRef = useRef(false); + + const { + recommendations, + skillProfile, + recommenderLoading, + recommenderError, + agentStep, + runRecommender, + } = useContributionRecommender(getOctokit); + + // Auto-run once when a username is available + useEffect(() => { + if (username && !hasRunRef.current) { + hasRunRef.current = true; + runRecommender(username); + } + }, [username, runRecommender]); + + // Reset auto-run flag if username changes + useEffect(() => { + hasRunRef.current = false; + }, [username]); + + if (!username) return null; + + return ( + + {/* Gradient top accent bar */} + + + + {/* Header */} + + + + + + + + AI Contribution Recommender + + + Agentic · 3-step pipeline · GitHub Search API + + + + + + + runRecommender(username, true)} + sx={{ + color: 'text.secondary', + '&:hover': { color: '#6366f1' }, + transition: 'color 0.2s', + }} + > + + + + + + + + + {/* Skill profile */} + {skillProfile && } + + {/* Loading state */} + + {recommenderLoading && ( + + + + )} + + + {/* Error state */} + {!recommenderLoading && recommenderError && ( + + runRecommender(username, true)} + sx={{ fontSize: '0.78rem', fontWeight: 700 }} + > + Retry + + } + > + {recommenderError} + + + )} + + {/* Recommendations */} + {!recommenderLoading && recommendations.length > 0 && ( + + + Top {recommendations.length} Recommended Issues for You + + + + {recommendations.map((rec, i) => ( + + ))} + + + + + Powered by GitHub Search API · Ranked by skill match, recency & repo popularity + + + )} + + {/* Empty state (after load, no error, no results) */} + {!recommenderLoading && !recommenderError && recommendations.length === 0 && ( + + + + Enter a GitHub username above and fetch your data to get AI-powered recommendations. + + + )} + + + ); +}; + +export default ContributionRecommender; diff --git a/src/hooks/useContributionRecommender.ts b/src/hooks/useContributionRecommender.ts new file mode 100644 index 00000000..b7513925 --- /dev/null +++ b/src/hooks/useContributionRecommender.ts @@ -0,0 +1,420 @@ +import { useState, useCallback, useRef } from 'react'; +import { Octokit } from '@octokit/core'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface SkillProfile { + topLanguages: string[]; + domains: string[]; + activityLevel: 'low' | 'medium' | 'high'; + repoCount: number; +} + +export interface Recommendation { + id: number; + issueTitle: string; + issueUrl: string; + issueNumber: number; + repoName: string; + repoUrl: string; + repoStars: number; + labels: string[]; + matchReason: string; + matchedLanguage: string; + difficulty: 'Beginner' | 'Intermediate'; + score: number; + createdAt: string; +} + +interface GitHubRepo { + name: string; + language: string | null; + stargazers_count: number; + forks_count: number; + pushed_at: string; + html_url: string; + fork: boolean; +} + +interface GitHubSearchIssue { + id: number; + number: number; + title: string; + html_url: string; + labels: Array<{ name: string }>; + repository_url: string; + created_at: string; + state: string; +} + +// ─── Agent Step 1: Skill Profiler ───────────────────────────────────────────── + +const DOMAIN_KEYWORDS: Record = { + frontend: ['react', 'vue', 'angular', 'next', 'svelte', 'ui', 'css', 'html', 'tailwind', 'vite'], + backend: ['api', 'server', 'express', 'fastapi', 'django', 'flask', 'spring', 'node', 'graphql'], + devops: ['docker', 'k8s', 'kubernetes', 'ci', 'deploy', 'terraform', 'aws', 'cloud', 'infra'], + mobile: ['android', 'ios', 'flutter', 'react-native', 'swift', 'kotlin'], + data: ['ml', 'ai', 'data', 'pandas', 'numpy', 'tensorflow', 'pytorch', 'jupyter'], +}; + +const inferDomains = (repos: GitHubRepo[]): string[] => { + const domainScores: Record = {}; + + repos.forEach((repo) => { + const name = repo.name.toLowerCase(); + Object.entries(DOMAIN_KEYWORDS).forEach(([domain, keywords]) => { + const matches = keywords.filter((kw) => name.includes(kw)).length; + if (matches > 0) { + domainScores[domain] = (domainScores[domain] || 0) + matches; + } + }); + }); + + return Object.entries(domainScores) + .sort(([, a], [, b]) => b - a) + .slice(0, 2) + .map(([domain]) => domain); +}; + +const buildSkillProfile = (repos: GitHubRepo[]): SkillProfile => { + // Count language frequency weighted by recency + const langScore: Record = {}; + const now = Date.now(); + + repos + .filter((r) => !r.fork && r.language) + .forEach((repo) => { + const lang = repo.language as string; + const ageMs = now - new Date(repo.pushed_at).getTime(); + const recencyBonus = Math.max(0, 1 - ageMs / (1000 * 60 * 60 * 24 * 365)); // decay over 1 year + langScore[lang] = (langScore[lang] || 0) + 1 + recencyBonus + repo.stargazers_count * 0.01; + }); + + const topLanguages = Object.entries(langScore) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + .map(([lang]) => lang); + + const domains = inferDomains(repos); + + // Activity level: based on number of recently pushed repos (last 90 days) + const ninetyDaysAgo = Date.now() - 90 * 24 * 60 * 60 * 1000; + const recentlyActive = repos.filter( + (r) => new Date(r.pushed_at).getTime() > ninetyDaysAgo + ).length; + + const activityLevel: SkillProfile['activityLevel'] = + recentlyActive >= 5 ? 'high' : recentlyActive >= 2 ? 'medium' : 'low'; + + return { + topLanguages, + domains, + activityLevel, + repoCount: repos.length, + }; +}; + +// ─── Agent Step 2: Issue Scout ──────────────────────────────────────────────── + +const SEARCH_LABELS = ['good first issue', 'help wanted']; + +const buildSearchQuery = (language: string, label: string): string => { + const encodedLabel = label.replace(/ /g, '+'); + return `label:"${encodedLabel}"+language:${language}+is:open+is:public+state:open`; +}; + +const scoutIssues = async ( + octokit: Octokit, + skillProfile: SkillProfile +): Promise => { + const queries: Array<{ language: string; label: string; query: string }> = []; + + // Build search combinations: top 2 languages × both labels + skillProfile.topLanguages.slice(0, 3).forEach((lang) => { + SEARCH_LABELS.forEach((label) => { + queries.push({ language: lang, label, query: buildSearchQuery(lang, label) }); + }); + }); + + const searchRequests = queries.map(({ query }) => + octokit + .request('GET /search/issues', { + q: query, + per_page: 8, + sort: 'updated', + order: 'desc', + }) + .then((res) => res.data.items as GitHubSearchIssue[]) + .catch(() => [] as GitHubSearchIssue[]) + ); + + const results = await Promise.allSettled(searchRequests); + const allIssues: GitHubSearchIssue[] = []; + const seenIds = new Set(); + + results.forEach((result) => { + if (result.status === 'fulfilled') { + result.value.forEach((issue) => { + if (!seenIds.has(issue.id)) { + seenIds.add(issue.id); + allIssues.push(issue); + } + }); + } + }); + + return allIssues; +}; + +// ─── Agent Step 3: Relevance Ranker ────────────────────────────────────────── + +const extractRepoInfo = async ( + octokit: Octokit, + repositoryUrl: string +): Promise<{ stars: number; repoName: string; repoUrl: string }> => { + try { + // repo URL format: https://api.github.com/repos/owner/name + const parts = repositoryUrl.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + + const res = await octokit.request('GET /repos/{owner}/{repo}', { owner, repo }); + return { + stars: res.data.stargazers_count, + repoName: `${owner}/${repo}`, + repoUrl: res.data.html_url, + }; + } catch { + const parts = repositoryUrl.split('/'); + const owner = parts[parts.length - 2]; + const repo = parts[parts.length - 1]; + return { stars: 0, repoName: `${owner}/${repo}`, repoUrl: repositoryUrl }; + } +}; + +const computeMatchReason = ( + issue: GitHubSearchIssue, + matchedLanguage: string, + skillProfile: SkillProfile, + langRank: number +): string => { + const isTopLang = langRank === 0; + const hasGoodFirstIssue = issue.labels.some((l) => + l.name.toLowerCase().includes('good first issue') + ); + const hasHelpWanted = issue.labels.some((l) => + l.name.toLowerCase().includes('help wanted') + ); + + if (isTopLang && hasGoodFirstIssue) { + return `${matchedLanguage} is your #1 language — perfect beginner-friendly entry point`; + } + if (isTopLang && hasHelpWanted) { + return `${matchedLanguage} is your primary language; this project needs contributors like you`; + } + if (langRank === 1) { + return `${matchedLanguage} is your #2 language — great way to deepen your expertise`; + } + if (skillProfile.domains.includes('frontend') && matchedLanguage === 'TypeScript') { + return `TypeScript aligns with your frontend domain activity`; + } + return `${matchedLanguage} is in your top languages — strong match with your coding profile`; +}; + +const scoreIssue = ( + issue: GitHubSearchIssue, + langRank: number, + stars: number +): number => { + let score = 0; + + // Language rank bonus (top language = highest score) + score += Math.max(0, (3 - langRank)) * 30; + + // Label quality + const labelNames = issue.labels.map((l) => l.name.toLowerCase()); + if (labelNames.includes('good first issue')) score += 25; + if (labelNames.includes('help wanted')) score += 15; + + // Star signal (capped) + score += Math.min(stars / 1000, 20); + + // Recency bonus (last 30 days = full 20pts) + const daysOld = + (Date.now() - new Date(issue.created_at).getTime()) / (1000 * 60 * 60 * 24); + score += Math.max(0, 20 - daysOld * 0.5); + + return score; +}; + +const rankIssues = async ( + octokit: Octokit, + rawIssues: GitHubSearchIssue[], + skillProfile: SkillProfile +): Promise => { + // Fetch repo info for top 15 candidates (to avoid too many requests) + const candidates = rawIssues.slice(0, 15); + + const enriched = await Promise.allSettled( + candidates.map(async (issue) => { + const { stars, repoName, repoUrl } = await extractRepoInfo( + octokit, + issue.repository_url + ); + + // Determine which language matched this issue + const labelNames = issue.labels.map((l) => l.name.toLowerCase()); + let matchedLanguage = skillProfile.topLanguages[0] || 'Unknown'; + let langRank = 0; + + // Try to detect language from label names first + for (let i = 0; i < skillProfile.topLanguages.length; i++) { + const lang = skillProfile.topLanguages[i].toLowerCase(); + if (labelNames.some((l) => l.includes(lang))) { + matchedLanguage = skillProfile.topLanguages[i]; + langRank = i; + break; + } + } + + const score = scoreIssue(issue, langRank, stars); + const matchReason = computeMatchReason(issue, matchedLanguage, skillProfile, langRank); + const hasGoodFirstIssue = labelNames.some((l) => l.includes('good first issue')); + + const rec: Recommendation = { + id: issue.id, + issueTitle: issue.title, + issueUrl: issue.html_url, + issueNumber: issue.number, + repoName, + repoUrl, + repoStars: stars, + labels: issue.labels.map((l) => l.name), + matchReason, + matchedLanguage, + difficulty: hasGoodFirstIssue ? 'Beginner' : 'Intermediate', + score, + createdAt: issue.created_at, + }; + + return rec; + }) + ); + + const ranked: Recommendation[] = enriched + .filter((r) => r.status === 'fulfilled') + .map((r) => (r as PromiseFulfilledResult).value) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + + return ranked; +}; + +// ─── Main Hook ──────────────────────────────────────────────────────────────── + +export const useContributionRecommender = (getOctokit: () => Octokit | null) => { + const [recommendations, setRecommendations] = useState([]); + const [skillProfile, setSkillProfile] = useState(null); + const [recommenderLoading, setRecommenderLoading] = useState(false); + const [recommenderError, setRecommenderError] = useState(''); + const [agentStep, setAgentStep] = useState<0 | 1 | 2 | 3>(0); + + // Cache: avoid re-running for the same username in the same session + const cacheRef = useRef<{ + username: string; + recommendations: Recommendation[]; + skillProfile: SkillProfile; + } | null>(null); + + const runRecommender = useCallback( + async (username: string, force = false) => { + const octokit = getOctokit(); + if (!octokit || !username.trim()) return; + + // Return cached result if available and not forced + if (!force && cacheRef.current?.username === username) { + setRecommendations(cacheRef.current.recommendations); + setSkillProfile(cacheRef.current.skillProfile); + return; + } + + setRecommenderLoading(true); + setRecommenderError(''); + setRecommendations([]); + setSkillProfile(null); + + try { + // ── Step 1: Skill Profiler ── + setAgentStep(1); + const reposRes = await octokit.request('GET /users/{username}/repos', { + username, + per_page: 30, + sort: 'pushed', + type: 'owner', + }); + + const repos = reposRes.data as GitHubRepo[]; + if (repos.length === 0) { + setRecommenderError('No public repositories found to build your skill profile.'); + return; + } + + const profile = buildSkillProfile(repos); + setSkillProfile(profile); + + if (profile.topLanguages.length === 0) { + setRecommenderError( + 'Could not detect languages from your repositories. Make sure your repos have languages set on GitHub.' + ); + return; + } + + // ── Step 2: Issue Scout ── + setAgentStep(2); + const rawIssues = await scoutIssues(octokit, profile); + + if (rawIssues.length === 0) { + setRecommenderError( + 'No matching open issues found right now. Try again later or add a GitHub token for better results.' + ); + return; + } + + // ── Step 3: Relevance Ranker ── + setAgentStep(3); + const ranked = await rankIssues(octokit, rawIssues, profile); + + setRecommendations(ranked); + + // Cache the result + cacheRef.current = { username, recommendations: ranked, skillProfile: profile }; + } catch (err: unknown) { + const error = err as { status?: number; message?: string }; + if (error.status === 403) { + setRecommenderError( + 'GitHub API rate limit hit. Add a Personal Access Token in the tracker to get recommendations.' + ); + } else if (error.status === 404) { + setRecommenderError('GitHub user not found. Please verify the username.'); + } else { + setRecommenderError( + 'Unable to generate recommendations. Please check your connection and try again.' + ); + } + } finally { + setRecommenderLoading(false); + setAgentStep(0); + } + }, + [getOctokit] + ); + + return { + recommendations, + skillProfile, + recommenderLoading, + recommenderError, + agentStep, + runRecommender, + }; +}; diff --git a/src/index.css b/src/index.css index 3f5943c5..940aedb9 100644 --- a/src/index.css +++ b/src/index.css @@ -1,91 +1,159 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@keyframes how-it-works-dash-flow { - from { - background-position: 0 0; - } - - to { - background-position: 72px 0; - } -} - -@keyframes how-it-works-dash-flow-vertical { - from { - background-position: 0 0; - } - - to { - background-position: 0 72px; - } -} - -.how-it-works-flow-line { - display: block; - height: 2px; - border-radius: 9999px; - background-image: repeating-linear-gradient( - 90deg, - rgba(96, 165, 250, 0.78) 0, - rgba(96, 165, 250, 0.78) 14px, - rgba(96, 165, 250, 0.08) 14px, - rgba(96, 165, 250, 0.08) 26px - ); - background-size: 28px 2px; - animation: how-it-works-dash-flow 10s linear infinite; - box-shadow: 0 0 18px rgba(59, 130, 246, 0.24); -} - -.how-it-works-flow-line.light { - background-image: repeating-linear-gradient( - 90deg, - rgba(59, 130, 246, 0.6) 0, - rgba(59, 130, 246, 0.6) 12px, - rgba(59, 130, 246, 0.12) 12px, - rgba(59, 130, 246, 0.12) 24px - ); - box-shadow: 0 0 14px rgba(59, 130, 246, 0.14); -} - -.how-it-works-flow-line.vertical { - width: 2px; - height: 100%; - background-image: repeating-linear-gradient( - 180deg, - rgba(96, 165, 250, 0.78) 0, - rgba(96, 165, 250, 0.78) 14px, - rgba(96, 165, 250, 0.08) 14px, - rgba(96, 165, 250, 0.08) 26px - ); - background-size: 2px 28px; - animation: how-it-works-dash-flow-vertical 10s linear infinite; -} - -.how-it-works-flow-line.vertical.light { - background-image: repeating-linear-gradient( - 180deg, - rgba(59, 130, 246, 0.6) 0, - rgba(59, 130, 246, 0.6) 12px, - rgba(59, 130, 246, 0.12) 12px, - rgba(59, 130, 246, 0.12) 24px - ); -} - - -.icon-merged { - color: #2ea44f; /* Or use your theme color */ -} -.icon-pr-open { - color: #0969da; -} -.icon-pr-closed { - color: #cf222e; -} -.icon-issue-open { - color: #2ea44f; -} -.icon-issue-closed { - color: #cf222e; -} +@tailwind base; +@tailwind components; +@tailwind utilities; + +@keyframes how-it-works-dash-flow { + from { + background-position: 0 0; + } + + to { + background-position: 72px 0; + } +} + +@keyframes how-it-works-dash-flow-vertical { + from { + background-position: 0 0; + } + + to { + background-position: 0 72px; + } +} + +.how-it-works-flow-line { + display: block; + height: 2px; + border-radius: 9999px; + background-image: repeating-linear-gradient( + 90deg, + rgba(96, 165, 250, 0.78) 0, + rgba(96, 165, 250, 0.78) 14px, + rgba(96, 165, 250, 0.08) 14px, + rgba(96, 165, 250, 0.08) 26px + ); + background-size: 28px 2px; + animation: how-it-works-dash-flow 10s linear infinite; + box-shadow: 0 0 18px rgba(59, 130, 246, 0.24); +} + +.how-it-works-flow-line.light { + background-image: repeating-linear-gradient( + 90deg, + rgba(59, 130, 246, 0.6) 0, + rgba(59, 130, 246, 0.6) 12px, + rgba(59, 130, 246, 0.12) 12px, + rgba(59, 130, 246, 0.12) 24px + ); + box-shadow: 0 0 14px rgba(59, 130, 246, 0.14); +} + +.how-it-works-flow-line.vertical { + width: 2px; + height: 100%; + background-image: repeating-linear-gradient( + 180deg, + rgba(96, 165, 250, 0.78) 0, + rgba(96, 165, 250, 0.78) 14px, + rgba(96, 165, 250, 0.08) 14px, + rgba(96, 165, 250, 0.08) 26px + ); + background-size: 2px 28px; + animation: how-it-works-dash-flow-vertical 10s linear infinite; +} + +.how-it-works-flow-line.vertical.light { + background-image: repeating-linear-gradient( + 180deg, + rgba(59, 130, 246, 0.6) 0, + rgba(59, 130, 246, 0.6) 12px, + rgba(59, 130, 246, 0.12) 12px, + rgba(59, 130, 246, 0.12) 24px + ); +} + + +.icon-merged { + color: #2ea44f; /* Or use your theme color */ +} +.icon-pr-open { + color: #0969da; +} +.icon-pr-closed { + color: #cf222e; +} +.icon-issue-open { + color: #2ea44f; +} +.icon-issue-closed { + color: #cf222e; +} + +html.theme-transitioning, +html.theme-transitioning *, +html.theme-transitioning *::before, +html.theme-transitioning *::after { + transition-property: background-color, border-color, color, fill, stroke, box-shadow, opacity, transform; + transition-duration: 600ms; + transition-timing-function: ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + html.theme-transitioning, + html.theme-transitioning *, + html.theme-transitioning *::before, + html.theme-transitioning *::after { + transition-duration: 0ms; + } +} + +@keyframes theme-icon-pop { + 0% { + opacity: 0; + transform: rotate(-90deg) scale(0.75); + } + + 100% { + opacity: 1; + transform: rotate(0deg) scale(1); + } +} + +.theme-toggle-icon { + animation: theme-icon-pop 500ms ease-out; +} + +/* ── Contribution Recommender Widget ─────────────────────────── */ + +@keyframes recommender-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.75); + } +} + +@keyframes recommender-shimmer { + 0% { + left: -100%; + } + 100% { + left: 200%; + } +} + +.recommender-card-hover { + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; +} + +.recommender-card-hover:hover { + transform: translateY(-2px); +} + +.agent-step-bar { + transition: background-color 0.4s ease; +} \ No newline at end of file diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 576f39bf..9ae99045 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react" +import React, { useState, useEffect, useContext } from "react" import { IssueOpenedIcon, IssueClosedIcon, @@ -10,7 +10,6 @@ import { Container, Box, TextField, - Button, Paper, Table, TableBody, @@ -28,11 +27,15 @@ import { MenuItem, FormControl, InputLabel, + Tooltip, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { useGitHubAuth } from "../../hooks/useGitHubAuth"; import { useGitHubData } from "../../hooks/useGitHubData"; +import DailyActivityStatus from "../../components/DailyActivityStatus"; +import ContributionRecommender from "../../components/ContributionRecommender"; import { KeyIcon } from "lucide-react"; +import BackToTopButton from "../../components/Backtotop"; const ROWS_PER_PAGE = 10; @@ -46,16 +49,46 @@ interface GitHubItem { html_url: string; } +const LANGUAGE_COLORS: Record = { + JavaScript: "#f1e05a", + TypeScript: "#3178c6", + Python: "#3572A5", + Java: "#b07219", + HTML: "#e34c26", + CSS: "#563d7c", + C: "#555555", + "C++": "#f34b7d", + "C#": "#178600", + PHP: "#4F5D95", + Ruby: "#701516", + Go: "#00ADD8", + Rust: "#dea584", + Kotlin: "#A97BFF", + Swift: "#F05138", +}; + +const getLanguageFromRepo = (repoName: string): string => { + const lowerRepo = repoName.toLowerCase(); + + if (lowerRepo.includes("react") || lowerRepo.includes("js")) return "JavaScript"; + if (lowerRepo.includes("ts") || lowerRepo.includes("typescript")) return "TypeScript"; + if (lowerRepo.includes("python") || lowerRepo.includes("py")) return "Python"; + if (lowerRepo.includes("java")) return "Java"; + if (lowerRepo.includes("html")) return "HTML"; + if (lowerRepo.includes("css")) return "CSS"; + + return "Unknown"; +}; const Home: React.FC = () => { const theme = useTheme(); + const userContext = useContext(UserContext); const { username, setUsername, token, setToken, - error: authError, getOctokit, } = useGitHubAuth(); @@ -64,13 +97,17 @@ const Home: React.FC = () => { prs, totalIssues, totalPrs, + contributionScore, loading, error: dataError, + dailyActivity, + dailyActivityLoaded, fetchData, } = useGitHubData(getOctokit); const [tab, setTab] = useState(0); const [page, setPage] = useState(0); + const [submittedUsername, setSubmittedUsername] = useState(""); const [issueFilter, setIssueFilter] = useState("all"); const [prFilter, setPrFilter] = useState("all"); @@ -79,20 +116,37 @@ const Home: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - // Fetch data when username, tab, or page changes + // Fetch data after submit, then refresh when tab or page changes. useEffect(() => { - if (username) { - fetchData(username, page + 1, ROWS_PER_PAGE); + if (submittedUsername) { + fetchData(submittedUsername, page + 1, ROWS_PER_PAGE); } - }, [tab, page]); + }, [fetchData, page, submittedUsername, tab]); const handleSubmit = (e: React.FormEvent): void => { e.preventDefault(); - setPage(0); - fetchData(username, 1, ROWS_PER_PAGE); + const trimmedUsername = username.trim(); + + if (!trimmedUsername) { + return; + } + + if (page !== 0) { + setPage(0); + } + + if (submittedUsername !== trimmedUsername) { + setSubmittedUsername(trimmedUsername); + return; + } + + if (page === 0) { + fetchData(trimmedUsername, 1, ROWS_PER_PAGE); + } }; const handlePageChange = (_: unknown, newPage: number) => { + setPage(newPage); }; @@ -162,86 +216,88 @@ const Home: React.FC = () => { const currentRawData = tab === 0 ? issues : prs; const currentFilteredData = filterData(currentRawData, tab === 0 ? issueFilter : prFilter); const totalCount = tab === 0 ? totalIssues : totalPrs; + const scoreItems = [ + { + label: "Merged PRs", + count: contributionScore.mergedPrs, + points: contributionScore.mergedPrs * 5, + weight: "+5 each", + }, + { + label: "Open PRs", + count: contributionScore.openPrs, + points: contributionScore.openPrs * 2, + weight: "+2 each", + }, + { + label: "Closed PRs", + count: contributionScore.closedPrs, + points: contributionScore.closedPrs, + weight: "+1 each", + }, + { + label: "Issues Created", + count: contributionScore.issuesCreated, + points: contributionScore.issuesCreated, + weight: "+1 each", + }, + ]; return ( {/* Auth Form */} -
- - setUsername(e.target.value)} - required - sx={{ flex: 1, minWidth: 150 }} - /> - setToken(e.target.value)} - type="password" - required - sx={{ flex: 1, minWidth: 150 }} - helperText={ - + setUsername(e.target.value)} + sx={{ flex: 1, minWidth: 150 }} + /> + setToken(e.target.value)} + type="password" + sx={{ flex: 1, minWidth: 150 }} + helperText={ + + - - - Generate new token - - - - • - - - - Learn more - - - } - /> - - - + + Generate new token + + + + Learn more + +
+ } + /> +
+ {dailyActivityLoaded && } + + {submittedUsername && ( + + )} + {/* Filters */} { State + - {(authError || dataError) && ( + {dataError && ( - {authError || dataError} + {dataError} )} + + + + + Contribution Score + + + {contributionScore.total} + + + + + {scoreItems.map((item) => ( + + + {item.label} + + + {item.count} + + + {item.points} pts - {item.weight} + + + ))} + + + + {loading ? ( @@ -369,8 +490,38 @@ const Home: React.FC = () => { - {item.repository_url.split("/").slice(-1)[0]} - + {(() => { + const repoName = item.repository_url.split("/").slice(-1)[0]; + const language = getLanguageFromRepo(repoName); + const color = LANGUAGE_COLORS[language] || "#9ca3af"; + + return ( + + + + {repoName} + + + ); + })()} + {item.pull_request?.merged_at ? "merged" : item.state} @@ -396,6 +547,7 @@ const Home: React.FC = () => { )} + ); }; From bb8858bad31e720541833f8dc5ce996c9f8c3466 Mon Sep 17 00:00:00 2001 From: aditya pichikala Date: Fri, 29 May 2026 23:37:32 +0530 Subject: [PATCH 2/2] fix: address CodeRabbit review feedback for Agentic AI Recommender - Fix search query construction to preserve spaces and include is:issue - Preserve language query context from scout to ranker to avoid inaccurate inference - Throw error on all search queries failing to properly bubble up rate limit / auth errors - Add requestId guard in runRecommender to prevent stale concurrent runs - Remove unused DailyActivityStatus and Backtotop imports from Tracker.tsx --- src/hooks/useContributionRecommender.ts | 82 +++++++++++++++---------- src/pages/Tracker/Tracker.tsx | 2 - 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/hooks/useContributionRecommender.ts b/src/hooks/useContributionRecommender.ts index b7513925..8a3f4642 100644 --- a/src/hooks/useContributionRecommender.ts +++ b/src/hooks/useContributionRecommender.ts @@ -47,6 +47,11 @@ interface GitHubSearchIssue { state: string; } +interface ScoutedIssue extends GitHubSearchIssue { + _matchedLanguage: string; + _langRank: number; +} + // ─── Agent Step 1: Skill Profiler ───────────────────────────────────────────── const DOMAIN_KEYWORDS: Record = { @@ -119,24 +124,23 @@ const buildSkillProfile = (repos: GitHubRepo[]): SkillProfile => { const SEARCH_LABELS = ['good first issue', 'help wanted']; const buildSearchQuery = (language: string, label: string): string => { - const encodedLabel = label.replace(/ /g, '+'); - return `label:"${encodedLabel}"+language:${language}+is:open+is:public+state:open`; + return `label:"${label}" language:${language} is:open is:public is:issue state:open`; }; const scoutIssues = async ( octokit: Octokit, skillProfile: SkillProfile -): Promise => { - const queries: Array<{ language: string; label: string; query: string }> = []; +): Promise => { + const queries: Array<{ language: string; langRank: number; label: string; query: string }> = []; - // Build search combinations: top 2 languages × both labels - skillProfile.topLanguages.slice(0, 3).forEach((lang) => { + // Build search combinations: top 3 languages × both labels + skillProfile.topLanguages.slice(0, 3).forEach((lang, langRank) => { SEARCH_LABELS.forEach((label) => { - queries.push({ language: lang, label, query: buildSearchQuery(lang, label) }); + queries.push({ language: lang, langRank, label, query: buildSearchQuery(lang, label) }); }); }); - const searchRequests = queries.map(({ query }) => + const searchRequests = queries.map(({ query, language, langRank }) => octokit .request('GET /search/issues', { q: query, @@ -144,26 +148,37 @@ const scoutIssues = async ( sort: 'updated', order: 'desc', }) - .then((res) => res.data.items as GitHubSearchIssue[]) - .catch(() => [] as GitHubSearchIssue[]) + .then((res) => res.data.items.map(issue => ({ + ...issue, + _matchedLanguage: language, + _langRank: langRank + })) as ScoutedIssue[]) ); const results = await Promise.allSettled(searchRequests); - const allIssues: GitHubSearchIssue[] = []; - const seenIds = new Set(); + + // If all search queries failed (e.g. rate limit, auth error), bubble up the error + const allRejected = results.length > 0 && results.every(r => r.status === 'rejected'); + if (allRejected) { + const firstError = (results[0] as PromiseRejectedResult).reason; + throw firstError; + } + + const seenIds = new Map(); results.forEach((result) => { if (result.status === 'fulfilled') { result.value.forEach((issue) => { - if (!seenIds.has(issue.id)) { - seenIds.add(issue.id); - allIssues.push(issue); + const existing = seenIds.get(issue.id); + // If an issue appears in multiple queries, keep the one with the better (lower) language rank + if (!existing || issue._langRank < existing._langRank) { + seenIds.set(issue.id, issue); } }); } }); - return allIssues; + return Array.from(seenIds.values()); }; // ─── Agent Step 3: Relevance Ranker ────────────────────────────────────────── @@ -249,7 +264,7 @@ const scoreIssue = ( const rankIssues = async ( octokit: Octokit, - rawIssues: GitHubSearchIssue[], + rawIssues: ScoutedIssue[], skillProfile: SkillProfile ): Promise => { // Fetch repo info for top 15 candidates (to avoid too many requests) @@ -262,20 +277,10 @@ const rankIssues = async ( issue.repository_url ); - // Determine which language matched this issue + // Use the language and rank preserved from the scout query + const matchedLanguage = issue._matchedLanguage; + const langRank = issue._langRank; const labelNames = issue.labels.map((l) => l.name.toLowerCase()); - let matchedLanguage = skillProfile.topLanguages[0] || 'Unknown'; - let langRank = 0; - - // Try to detect language from label names first - for (let i = 0; i < skillProfile.topLanguages.length; i++) { - const lang = skillProfile.topLanguages[i].toLowerCase(); - if (labelNames.some((l) => l.includes(lang))) { - matchedLanguage = skillProfile.topLanguages[i]; - langRank = i; - break; - } - } const score = scoreIssue(issue, langRank, stars); const matchReason = computeMatchReason(issue, matchedLanguage, skillProfile, langRank); @@ -325,6 +330,9 @@ export const useContributionRecommender = (getOctokit: () => Octokit | null) => recommendations: Recommendation[]; skillProfile: SkillProfile; } | null>(null); + + // Guard against race conditions from multiple runs + const runRequestIdRef = useRef(0); const runRecommender = useCallback( async (username: string, force = false) => { @@ -338,6 +346,8 @@ export const useContributionRecommender = (getOctokit: () => Octokit | null) => return; } + const currentRequestId = ++runRequestIdRef.current; + setRecommenderLoading(true); setRecommenderError(''); setRecommendations([]); @@ -360,6 +370,7 @@ export const useContributionRecommender = (getOctokit: () => Octokit | null) => } const profile = buildSkillProfile(repos); + if (currentRequestId !== runRequestIdRef.current) return; setSkillProfile(profile); if (profile.topLanguages.length === 0) { @@ -370,6 +381,7 @@ export const useContributionRecommender = (getOctokit: () => Octokit | null) => } // ── Step 2: Issue Scout ── + if (currentRequestId !== runRequestIdRef.current) return; setAgentStep(2); const rawIssues = await scoutIssues(octokit, profile); @@ -381,14 +393,18 @@ export const useContributionRecommender = (getOctokit: () => Octokit | null) => } // ── Step 3: Relevance Ranker ── + if (currentRequestId !== runRequestIdRef.current) return; setAgentStep(3); const ranked = await rankIssues(octokit, rawIssues, profile); + if (currentRequestId !== runRequestIdRef.current) return; setRecommendations(ranked); // Cache the result cacheRef.current = { username, recommendations: ranked, skillProfile: profile }; } catch (err: unknown) { + if (currentRequestId !== runRequestIdRef.current) return; + const error = err as { status?: number; message?: string }; if (error.status === 403) { setRecommenderError( @@ -402,8 +418,10 @@ export const useContributionRecommender = (getOctokit: () => Octokit | null) => ); } } finally { - setRecommenderLoading(false); - setAgentStep(0); + if (currentRequestId === runRequestIdRef.current) { + setRecommenderLoading(false); + setAgentStep(0); + } } }, [getOctokit] diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 9ae99045..559665db 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -32,10 +32,8 @@ import { import { useTheme } from "@mui/material/styles"; import { useGitHubAuth } from "../../hooks/useGitHubAuth"; import { useGitHubData } from "../../hooks/useGitHubData"; -import DailyActivityStatus from "../../components/DailyActivityStatus"; import ContributionRecommender from "../../components/ContributionRecommender"; import { KeyIcon } from "lucide-react"; -import BackToTopButton from "../../components/Backtotop"; const ROWS_PER_PAGE = 10;