diff --git a/package.json b/package.json index c492150..c16f966 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.15.3", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "vaul": "^1.1.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7653729..f847822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: tailwind-merge: specifier: ^3.3.0 version: 3.3.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -2450,6 +2453,12 @@ packages: '@types/react': optional: true + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -5022,6 +5031,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.5 + vaul@1.1.2(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.5))(@types/react@19.1.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.1 diff --git a/src/app/analytics/[owner]/[repo]/page.tsx b/src/app/analytics/[owner]/[repo]/page.tsx index 4c5bfd3..dde611e 100644 --- a/src/app/analytics/[owner]/[repo]/page.tsx +++ b/src/app/analytics/[owner]/[repo]/page.tsx @@ -6,19 +6,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Star, GitFork, Users, TrendingUp, AlertTriangle, Code, Activity, Shield } from "lucide-react"; +import { Star, GitFork, Users, TrendingUp, AlertTriangle, Code, Activity, Shield } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; -// Import our new components import GrowthChart from "@/components/charts/GrowthChart"; import LanguageChart from "@/components/charts/LanguageChart"; import ContributorChart from "@/components/charts/ContributorChart"; import RiskAssessment from "@/components/analytics/RiskAssessment"; +import { BeginnerIssuesDrawer } from "@/components/github/BeginnerIssuesDrawer"; -import { AdvancedAnalytics } from "@/lib/github"; - - +import { Repository, HistoricalData, ContributorData, TechnologyStack, RiskAssessment as RiskAssessmentType } from "@/lib/github"; interface CompetitiveAnalysis { @@ -62,12 +60,96 @@ interface CompetitiveAnalysis { }; } +interface ExtendedAnalytics { + repository: Repository; + contributors: ContributorData[]; + releases: number; + issues: { + open: number; + closed: number; + }; + pullRequests: { + open: number; + closed: number; + merged: number; + }; + commits: { + total: number; + lastMonth: number; + }; + beginnerIssues?: { + totalCount: number; + nodes: Array<{ + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; + }>; + }; + historical: HistoricalData[]; + technologyStack: TechnologyStack; + riskAssessment: RiskAssessmentType; + trends: { + starsGrowth: number; + forksGrowth: number; + contributorsGrowth: number; + commitActivity: number; + }; +} + +// Helper function to check if repository has beginner-friendly issues +function hasBeginnersIssues(repository: Repository): boolean { + if (!repository?.beginnerIssues?.nodes?.length) { + return false; + } + + const BEGINNER_LABEL_KEYWORDS = [ + 'good first issue', + 'beginner', + 'starter', + 'easy', + 'help wanted', + 'first-timer', + 'first timer', + 'good-first-issue', + 'good first', + 'help-wanted', + 'up-for-grabs', + 'low hanging fruit', + 'new contributor', + 'entry level', + 'easy pick', + 'easy fix', + 'newbie', + 'junior', + 'trivial', + ]; + + return repository.beginnerIssues.nodes.some((issue) => { + if (!issue.labels?.nodes?.length) return false; + + return issue.labels.nodes.some((label) => { + const labelName = label.name.toLowerCase(); + return BEGINNER_LABEL_KEYWORDS.some(keyword => + labelName.includes(keyword.toLowerCase()) + ); + }); + }); +} + export default function AnalyticsPage() { const params = useParams(); const owner = params.owner as string; const repo = params.repo as string; - const [analytics, setAnalytics] = useState(null); + const [analytics, setAnalytics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [competitiveAnalysis, setCompetitiveAnalysis] = useState(null); @@ -77,16 +159,47 @@ export default function AnalyticsPage() { const fetchAnalytics = async () => { try { setLoading(true); - const response = await fetch(`/api/github/advanced-analytics?owner=${owner}&name=${repo}`); + setError(null); // Clear previous errors + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 45000); // 45 second timeout + + const response = await fetch(`/api/github/advanced-analytics?owner=${owner}&name=${repo}`, { + signal: controller.signal, + headers: { + 'Cache-Control': 'no-cache', + }, + }); + + clearTimeout(timeoutId); + const data = await response.json(); if (!response.ok) { - throw new Error(data.error || "Failed to fetch repository analytics"); + // Handle specific error cases + if (response.status === 404) { + throw new Error(`Repository "${owner}/${repo}" not found or is private`); + } else if (response.status === 408) { + throw new Error("Request timed out. This repository might be very large. Please try again."); + } else if (response.status === 429) { + throw new Error("Too many requests. Please wait a moment and try again."); + } else { + throw new Error(data.error || "Failed to fetch repository analytics"); + } } - setAnalytics(data); + setAnalytics(data as ExtendedAnalytics); } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); + if (err instanceof Error) { + if (err.name === 'AbortError') { + setError("Request timed out. Please try again."); + } else { + setError(err.message); + } + } else { + setError("An unexpected error occurred. Please try again."); + } + console.error("Analytics fetch error:", err); } finally { setLoading(false); } @@ -127,17 +240,29 @@ export default function AnalyticsPage() { if (error) { return (
-
+
Error loading analytics
-
{error}
- +
{error}
+
+ + +
+
+ If the problem persists, the repository might be private, very large, or temporarily unavailable. +
); @@ -152,397 +277,453 @@ export default function AnalyticsPage() { } const { repository, historical, contributors, technologyStack, riskAssessment, trends } = analytics; + const hasBeginnerIssues = hasBeginnersIssues(repository); return ( -
-
- {/* Header */} -
- - - -
+
+ {/* Repository Header */} + + +
{repository.owner.login} -
-

{repository.fullName}

-

{repository.description}

-
- {repository.language && ( - - {repository.language} - - )} - - {repository.stargazerCount.toLocaleString()} stars - - - {repository.forkCount.toLocaleString()} forks +
+ + {repository.fullName} + +

{repository.description}

+
+
+ +
+
+ + {repository.stargazerCount.toLocaleString()} + + + {repository.forkCount.toLocaleString()} + + {repository.language && ( + + {repository.language} -
+ )}
+ + {hasBeginnerIssues && repository.beginnerIssues && ( + + )}
-
- - {/* Tabs Navigation */} - - - - - Overview +
+
+ + {/* Tabs Navigation */} + +
+ + + + Overview - - - Growth + + + Growth - - - Contributors + + + Contributors - - - Tech Stack + + + Tech - - - Risk Assessment + + + Risk +
+
+ Swipe to see more tabs +
- {/* Overview Tab */} - - {/* Key Metrics Grid */} -
- - - Stars - - - -
{repository.stargazerCount.toLocaleString()}
-

- {trends.starsGrowth > 0 ? ( - - ) : ( - - )} - {Math.abs(trends.starsGrowth)}% growth -

-
-
- - - - Forks - - - -
{repository.forkCount.toLocaleString()}
-

- {trends.forksGrowth > 0 ? ( - - ) : ( - - )} - {Math.abs(trends.forksGrowth)}% growth -

-
-
- - - - Contributors - - - -
- {contributors.length > 0 ? contributors.length : "N/A"} -
-

- {contributors.length > 0 ? "Active contributors" : "Data unavailable"} -

-
-
- - - - Health Score - - - -
- {Math.round((riskAssessment.busFactor.score + riskAssessment.maintenanceStatus.score + riskAssessment.communityHealth.score) / 3)}% -
-

Overall health

-
-
-
+ {/* Overview Tab */} + + {/* Key Metrics Grid */} +
+ + + Stars + + + +
{repository.stargazerCount.toLocaleString()}
+

+ {trends?.starsGrowth > 0 ? ( + + ) : ( + + )} + {Math.abs(trends?.starsGrowth ?? 0)}% growth +

+
+
- {/* Competitive Analysis */} - - Competitive Analysis - + + Forks + - {competitiveAnalysis ? ( -
-
-
-
- {competitiveAnalysis.analysis.competitivePosition.position} -
-
Market Position
+
{repository.forkCount.toLocaleString()}
+

+ {trends?.forksGrowth > 0 ? ( + + ) : ( + + )} + {Math.abs(trends?.forksGrowth ?? 0)}% growth +

+ + + + + + Contributors + + + +
+ {contributors?.length?.toLocaleString() ?? 0} +
+

+ Active contributors +

+
+
+ + + + Health Score + + + +
+ {Math.round( + ((riskAssessment?.busFactor?.score ?? 0) + + (riskAssessment?.maintenanceStatus?.score ?? 0) + + (riskAssessment?.communityHealth?.score ?? 0)) / 3 + )}% +
+

Overall health

+
+
+
+ + {/* Competitive Analysis */} + + + Competitive Analysis + + + + {competitiveAnalysis ? ( +
+
+
+
+ {competitiveAnalysis.analysis.competitivePosition.position}
-
-
- {competitiveAnalysis.analysis.competitivePosition.percentile}% -
-
Better than competitors
+
Market Position
+
+
+
+ {competitiveAnalysis.analysis.competitivePosition.percentile}%
-
-
- {Math.round(competitiveAnalysis.analysis.averageStars).toLocaleString()} -
-
Avg competitor stars
+
Better than competitors
+
+
+
+ {Math.round(competitiveAnalysis.analysis.averageStars).toLocaleString()}
+
Avg competitor stars
- ) : ( -
- Click "Analyze Competition" to discover similar repositories and your competitive position -
- )} +
+ ) : ( +
+ Click "Analyze Competition" to discover similar repositories and your competitive position +
+ )} + + + + + {/* Growth Trends Tab */} + + + + Historical Growth Trends + + + {historical?.length > 0 ? ( + + ) : ( +
+ No historical data available +
+ )} +
+
+ + {/* Growth Metrics */} +
+ + + Stars Growth + + +
+ {trends?.starsGrowth > 0 ? '+' : ''}{trends?.starsGrowth ?? 0}% +
+

Last 3 months vs previous 3 months

- - {/* Growth Trends Tab */} - - Historical Growth Trends + Forks Growth - {historical.length > 0 ? ( - - ) : ( -
- No historical data available -
- )} +
+ {trends?.forksGrowth > 0 ? '+' : ''}{trends?.forksGrowth ?? 0}% +
+

Last 3 months vs previous 3 months

- {/* Growth Metrics */} -
- - - Stars Growth - - -
- {trends.starsGrowth > 0 ? '+' : ''}{trends.starsGrowth}% -
-

Last 3 months vs previous 3 months

-
-
- - - - Forks Growth - - -
- {trends.forksGrowth > 0 ? '+' : ''}{trends.forksGrowth}% -
-

Last 3 months vs previous 3 months

-
-
- - - - Commit Activity - - -
- {trends.commitActivity > 0 ? '+' : ''}{trends.commitActivity}% -
-

Recent activity trend

-
-
-
-
+ + + Commit Activity + + +
+ {trends?.commitActivity > 0 ? '+' : ''}{trends?.commitActivity ?? 0}% +
+

Recent activity trend

+
+
+
+
+ + {/* Contributors Tab */} + + + + Top Contributors + + + + + + {contributors?.length > 0 ? ( + + ) : ( +
+ No contributor data available +
+ )} +
+
+ + {/* Top Contributors List */} + {contributors?.length > 0 && ( + + + Contributor Details + + +
+ {contributors.slice(0, 8).map((contributor) => ( +
+ {contributor.login} +
+
{contributor.login}
+
{contributor.contributions} contributions
+
+ + #{contributors.indexOf(contributor) + 1} + +
+ ))} +
+
+
+ )} +
- {/* Contributors Tab */} - + {/* Technology Stack Tab */} + +
+ {/* Language Distribution */} - - Top Contributors - - - + + Language Distribution - {contributors.length > 0 ? ( - + {technologyStack?.languages?.length > 0 ? ( + ) : (
- No contributor data available + No language data available
)}
- {/* Top Contributors List */} - {contributors.length > 0 && ( - - - Contributor Details - - -
- {contributors.slice(0, 8).map((contributor, index) => ( -
- {contributor.login} -
-
{contributor.login}
-
{contributor.contributions} contributions
-
- - #{index + 1} + {/* Frameworks & Technologies */} + + + Frameworks & Technologies + + + {technologyStack?.frameworks?.length > 0 ? ( +
+

Detected Frameworks:

+
+ {technologyStack.frameworks.map((framework, index) => ( + + {framework} -
- ))} -
-
-
- )} - - - {/* Technology Stack Tab */} - -
- {/* Language Distribution */} - - - Language Distribution - - - {technologyStack.languages.length > 0 ? ( - - ) : ( -
- No language data available + ))}
- )} -
-
- - {/* Frameworks & Technologies */} - - - Frameworks & Technologies - - - {technologyStack.frameworks.length > 0 ? ( -
-

Detected Frameworks:

-
- {technologyStack.frameworks.map((framework, index) => ( - - {framework} - - ))} -
-
- ) : ( -
No frameworks detected
- )} +
+ ) : ( +
No frameworks detected
+ )} - {technologyStack.languages.length > 0 && ( -
-

Language Breakdown:

-
- {technologyStack.languages.slice(0, 5).map((lang) => ( -
- {lang.name} - {lang.percentage}% -
- ))} -
+ {technologyStack?.languages?.length > 0 && ( +
+

Language Breakdown:

+
+ {technologyStack.languages.slice(0, 5).map((lang) => ( +
+ {lang.name} + {lang.percentage}% +
+ ))}
- )} - - -
- - {/* Dependencies */} - {technologyStack.dependencies.length > 0 && ( - - - Dependencies - - -
- {technologyStack.dependencies.map((dep, index) => ( -
-
{dep.name}
-
{dep.version}
- - {dep.type} - -
- ))}
-
-
- )} - + )} + + +
+ + {/* Dependencies */} + {technologyStack?.dependencies?.length > 0 && ( + + + Dependencies + + +
+ {technologyStack.dependencies.map((dep) => ( +
+
{dep.name}
+
{dep.version}
+ + {dep.type} + +
+ ))} +
+
+
+ )} +
- {/* Risk Assessment Tab */} - + {/* Risk Assessment Tab */} + + {riskAssessment ? ( - - - - {/* Actions */} -
- - -
+ ) : ( + + +
+ No risk assessment data available +
+
+
+ )} +
+ + + {/* Actions */} +
+ +
); diff --git a/src/app/api/github/advanced-analytics/route.ts b/src/app/api/github/advanced-analytics/route.ts index 95135d6..4e2f50a 100644 --- a/src/app/api/github/advanced-analytics/route.ts +++ b/src/app/api/github/advanced-analytics/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { GitHubService } from '@/lib/github'; export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const owner = searchParams.get('owner'); - const name = searchParams.get('name'); + const { searchParams } = new URL(request.url); + const owner = searchParams.get('owner'); + const name = searchParams.get('name'); + try { if (!owner || !name) { return NextResponse.json( { error: 'Owner and name parameters are required' }, @@ -14,13 +14,61 @@ export async function GET(request: NextRequest) { ); } - const analytics = await GitHubService.getAdvancedAnalytics(owner, name); + // Validate input parameters + if (!/^[a-zA-Z0-9._-]+$/.test(owner) || !/^[a-zA-Z0-9._-]+$/.test(name)) { + return NextResponse.json( + { error: 'Invalid repository owner or name format' }, + { status: 400 } + ); + } + + // Add timeout to the entire operation + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Request timeout')), 30000); // 30 second timeout + }); + + const analyticsPromise = GitHubService.getAdvancedAnalytics(owner, name); + + const analytics = await Promise.race([analyticsPromise, timeoutPromise]); + + // Add cache headers for successful responses + const response = NextResponse.json(analytics); + response.headers.set('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600'); // 5 min cache, 10 min stale + + return response; - return NextResponse.json(analytics); } catch (error) { console.error('Error in advanced analytics API:', error); + + // Provide more specific error messages + if (error instanceof Error) { + if (error.message.includes('not found') || error.message.includes('inaccessible')) { + return NextResponse.json( + { error: `Repository ${owner}/${name} not found or is private` }, + { status: 404 } + ); + } + + if (error.message.includes('timeout')) { + return NextResponse.json( + { error: 'Request timed out. The repository might be too large or GitHub API is slow. Please try again.' }, + { status: 408 } + ); + } + + if (error.message.includes('rate limit')) { + return NextResponse.json( + { error: 'GitHub API rate limit exceeded. Please try again later.' }, + { status: 429 } + ); + } + } + return NextResponse.json( - { error: 'Failed to fetch advanced analytics' }, + { + error: 'Failed to fetch repository analytics. Please check if the repository exists and try again.', + details: process.env.NODE_ENV === 'development' ? error instanceof Error ? error.message : 'Unknown error' : undefined + }, { status: 500 } ); } diff --git a/src/components/charts/LanguageChart.tsx b/src/components/charts/LanguageChart.tsx index 71f03dc..1cbf141 100644 --- a/src/components/charts/LanguageChart.tsx +++ b/src/components/charts/LanguageChart.tsx @@ -83,9 +83,9 @@ export default function LanguageChart({ data, height = 300 }: LanguageChartProps /> ( - - {value} ({entry.payload?.percentage || 0}%) + formatter={(value, entry) => ( + + {value} ({(entry.payload as { percentage?: number })?.percentage || 0}%) )} /> diff --git a/src/components/github/BeginnerIssues.tsx b/src/components/github/BeginnerIssues.tsx new file mode 100644 index 0000000..c7f8f79 --- /dev/null +++ b/src/components/github/BeginnerIssues.tsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { HandHelping, ChevronDown, ChevronUp } from "lucide-react"; +import { formatDate } from '@/lib/utils'; + +interface BeginnerIssue { + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; +} + +interface BeginnerIssuesProps { + issues: { + totalCount: number; + pageInfo?: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: BeginnerIssue[]; + }; +} + +// List of beginner-friendly label keywords (case-insensitive) +const BEGINNER_LABEL_KEYWORDS = [ + 'good first issue', + 'beginner', + 'starter', + 'easy', + 'help wanted', + 'first-timer', + 'first timer', + 'good-first-issue', + 'good first', + 'help-wanted', + 'up-for-grabs', + 'low hanging fruit', + 'new contributor', + 'entry level', + 'easy pick', + 'easy fix', + 'newbie', + 'junior', + 'trivial', +]; + +// Helper function to check if an issue has a beginner-friendly label +function hasBeginnersLabel(issue: BeginnerIssue): boolean { + if (!issue.labels || !issue.labels.nodes || issue.labels.nodes.length === 0) { + return false; + } + + return issue.labels.nodes.some(label => { + const labelName = label.name.toLowerCase(); + return BEGINNER_LABEL_KEYWORDS.some(keyword => + labelName.includes(keyword.toLowerCase()) + ); + }); +} + +export function BeginnerIssues({ issues }: BeginnerIssuesProps) { + const [displayCount, setDisplayCount] = useState(10); + const [isExpanded, setIsExpanded] = useState(false); + + if (!issues || issues.totalCount === 0) { + return null; + } + + // Filter issues with beginner-friendly labels + const beginnerFriendlyIssues = issues.nodes + .filter(hasBeginnersLabel) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + if (beginnerFriendlyIssues.length === 0) { + return null; + } + + const visibleIssues = beginnerFriendlyIssues.slice(0, displayCount); + const hasMoreIssues = beginnerFriendlyIssues.length > displayCount; + + const handleShowMore = () => { + setDisplayCount(prev => Math.min(prev + 10, beginnerFriendlyIssues.length)); + setIsExpanded(true); + }; + + const handleShowLess = () => { + setDisplayCount(10); + setIsExpanded(false); + }; + + return ( + + + + + Beginner-Friendly Issues ({beginnerFriendlyIssues.length}) + + + + + + {(hasMoreIssues || isExpanded) && ( + + {hasMoreIssues && !isExpanded && ( + + )} + {hasMoreIssues && isExpanded && ( + + )} + {isExpanded && ( + + )} + + )} + + ); +} \ No newline at end of file diff --git a/src/components/github/BeginnerIssuesDrawer.tsx b/src/components/github/BeginnerIssuesDrawer.tsx new file mode 100644 index 0000000..dee8dc0 --- /dev/null +++ b/src/components/github/BeginnerIssuesDrawer.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { HandHelping, ChevronDown, ChevronUp, X, ExternalLink } from "lucide-react"; +import { formatDate } from '@/lib/utils'; + +interface BeginnerIssue { + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; +} + +interface BeginnerIssuesDrawerProps { + issues: { + totalCount: number; + pageInfo?: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: BeginnerIssue[]; + }; + repositoryName: string; +} + +// List of beginner-friendly label keywords (case-insensitive) +const BEGINNER_LABEL_KEYWORDS = [ + 'good first issue', + 'beginner', + 'starter', + 'easy', + 'help wanted', + 'first-timer', + 'first timer', + 'good-first-issue', + 'good first', + 'help-wanted', + 'up-for-grabs', + 'low hanging fruit', + 'new contributor', + 'entry level', + 'easy pick', + 'easy fix', + 'newbie', + 'junior', + 'trivial', +]; + +// Helper function to check if an issue has a beginner-friendly label +function hasBeginnersLabel(issue: BeginnerIssue): boolean { + if (!issue.labels || !issue.labels.nodes || issue.labels.nodes.length === 0) { + return false; + } + + return issue.labels.nodes.some(label => { + const labelName = label.name.toLowerCase(); + return BEGINNER_LABEL_KEYWORDS.some(keyword => + labelName.includes(keyword.toLowerCase()) + ); + }); +} + +export function BeginnerIssuesDrawer({ issues, repositoryName }: BeginnerIssuesDrawerProps) { + const [displayCount, setDisplayCount] = useState(10); + const [isExpanded, setIsExpanded] = useState(false); + + if (!issues || issues.totalCount === 0) { + return null; + } + + // Filter issues with beginner-friendly labels + const beginnerFriendlyIssues = issues.nodes + .filter(hasBeginnersLabel) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + if (beginnerFriendlyIssues.length === 0) { + return null; + } + + const visibleIssues = beginnerFriendlyIssues.slice(0, displayCount); + const hasMoreIssues = beginnerFriendlyIssues.length > displayCount; + + const handleShowMore = () => { + setDisplayCount(prev => Math.min(prev + 10, beginnerFriendlyIssues.length)); + setIsExpanded(true); + }; + + const handleShowLess = () => { + setDisplayCount(10); + setIsExpanded(false); + }; + + return ( + + + + + +
+ +
+
+ + + Beginner-Friendly Issues + + + {beginnerFriendlyIssues.length} beginner-friendly issues found in {repositoryName} + +
+ + + +
+
+ +
+
+ {visibleIssues.map((issue) => ( +
+
+
+
+ #{issue.number} + + + +
+

+ {issue.title} +

+
+ {issue.labels.nodes.map((label) => ( + + {label.name} + + ))} +
+
+
+ {formatDate(issue.createdAt)} +
+
+ + View on GitHub + + +
+ ))} +
+ + {(hasMoreIssues || isExpanded) && ( +
+ {hasMoreIssues && ( + + )} + {isExpanded && ( + + )} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..199023b --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/src/lib/github-types.ts b/src/lib/github-types.ts index 6f41ddf..8bb9562 100644 --- a/src/lib/github-types.ts +++ b/src/lib/github-types.ts @@ -42,6 +42,22 @@ export interface GitHubRepositoryResponse { issues: { totalCount: number; }; + beginnerIssues: { + totalCount: number; + nodes: Array<{ + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; + }>; + }; closedIssues: { totalCount: number; }; @@ -109,6 +125,22 @@ export interface GitHubSearchResponse { name: string; key: string; } | null; + beginnerIssues: { + totalCount: number; + nodes: Array<{ + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; + }>; + }; }>; }; } \ No newline at end of file diff --git a/src/lib/github.ts b/src/lib/github.ts index 3ec97ec..7356cab 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -53,6 +53,22 @@ export interface Repository { name: string; key: string; } | null; + beginnerIssues?: { + totalCount: number; + nodes: Array<{ + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; + }>; + }; } export interface RepositoryStats { @@ -72,6 +88,22 @@ export interface RepositoryStats { total: number; lastMonth: number; }; + beginnerIssues?: { + totalCount: number; + nodes: Array<{ + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; + }>; + }; } export interface HistoricalData { @@ -126,8 +158,38 @@ export interface RiskAssessment { export interface AdvancedAnalytics { repository: Repository; - historical: HistoricalData[]; contributors: ContributorData[]; + releases: number; + issues: { + open: number; + closed: number; + }; + pullRequests: { + open: number; + closed: number; + merged: number; + }; + commits: { + total: number; + lastMonth: number; + }; + beginnerIssues?: { + totalCount: number; + nodes: Array<{ + id: string; + number: number; + title: string; + url: string; + createdAt: string; + labels: { + nodes: Array<{ + name: string; + color: string; + }>; + }; + }>; + }; + historical: HistoricalData[]; technologyStack: TechnologyStack; riskAssessment: RiskAssessment; trends: { @@ -138,7 +200,7 @@ export interface AdvancedAnalytics { }; } - export interface ContributorCommitData { +export interface ContributorCommitData { week: string; // ISO date string for the week commits: number; additions: number; @@ -231,6 +293,26 @@ export const REPOSITORY_QUERY = ` issues(states: [OPEN]) { totalCount } + beginnerIssues: issues(states: [OPEN], first: 100) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + number + title + url + createdAt + labels(first: 10) { + nodes { + name + color + } + } + } + } closedIssues: issues(states: [CLOSED]) { totalCount } @@ -308,6 +390,26 @@ export const SEARCH_REPOSITORIES_QUERY = ` name key } + beginnerIssues: issues(states: [OPEN], first: 100) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + number + title + url + createdAt + labels(first: 10) { + nodes { + name + color + } + } + } + } } } } @@ -321,10 +423,21 @@ export class GitHubService { */ static async getRepositoryStats(owner: string, name: string): Promise { try { - const response = await getGitHubGraphQL()(REPOSITORY_QUERY, { + // Add timeout to GraphQL request + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('GraphQL request timeout')), 15000); // 15 second timeout + }); + + const graphqlPromise = getGitHubGraphQL()(REPOSITORY_QUERY, { owner, name, - }) as GitHubRepositoryResponse; + }) as Promise; + + const response = await Promise.race([graphqlPromise, timeoutPromise]); + + if (!response?.repository) { + throw new Error(`Repository ${owner}/${name} not found`); + } const repo = response.repository; @@ -334,8 +447,8 @@ export class GitHubService { const restClient = getGitHubRest(); // Create a timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Contributors request timeout')), 5000); // 5 second timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Contributors request timeout')), 8000); // 8 second timeout }); // Race between the API call and timeout @@ -346,7 +459,7 @@ export class GitHubService { per_page: 1 }), timeoutPromise - ]) as { data: any[]; headers: { link?: string } }; + ]) as { data: unknown[]; headers: { link?: string } }; // GitHub returns the total count in the Link header for pagination const linkHeader = contributorsResponse.headers.link; @@ -363,16 +476,21 @@ export class GitHubService { // Try alternative approach using GraphQL if REST fails try { - const contributorsGraphQL = await getGitHubGraphQL()( - `query GetContributors($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - mentionableUsers(first: 1) { - totalCount + const contributorsGraphQL = await Promise.race([ + getGitHubGraphQL()( + `query GetContributors($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: 1) { + totalCount + } } - } - }`, - { owner, name } - ) as { repository?: { mentionableUsers?: { totalCount: number } } }; + }`, + { owner, name } + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('GraphQL contributors timeout')), 5000) + ) + ]) as { repository?: { mentionableUsers?: { totalCount: number } } }; contributorsCount = contributorsGraphQL.repository?.mentionableUsers?.totalCount || 0; } catch { @@ -388,45 +506,53 @@ export class GitHubService { fullName: repo.nameWithOwner, description: repo.description, url: repo.url, - stargazerCount: repo.stargazerCount, - forkCount: repo.forkCount, - watcherCount: repo.watchers.totalCount, + stargazerCount: repo.stargazerCount || 0, + forkCount: repo.forkCount || 0, + watcherCount: repo.watchers?.totalCount || 0, language: repo.primaryLanguage?.name || null, - topics: repo.repositoryTopics.nodes.map((node) => node.topic.name), + topics: repo.repositoryTopics?.nodes?.map((node) => node.topic.name) || [], createdAt: repo.createdAt, updatedAt: repo.updatedAt, pushedAt: repo.pushedAt, - isArchived: repo.isArchived, - isPrivate: repo.isPrivate, + isArchived: repo.isArchived || false, + isPrivate: repo.isPrivate || false, owner: { login: repo.owner.login, - type: repo.owner.__typename, - avatarUrl: repo.owner.avatarUrl, + type: repo.owner.__typename || 'User', + avatarUrl: repo.owner.avatarUrl || '', }, license: repo.licenseInfo ? { name: repo.licenseInfo.name, key: repo.licenseInfo.key, } : null, + beginnerIssues: repo.beginnerIssues, }, contributors: contributorsCount, - releases: repo.releases.totalCount, + releases: repo.releases?.totalCount || 0, issues: { - open: repo.issues.totalCount, - closed: repo.closedIssues.totalCount, + open: repo.issues?.totalCount || 0, + closed: repo.closedIssues?.totalCount || 0, }, pullRequests: { - open: repo.pullRequests.totalCount, - closed: repo.closedPullRequests.totalCount, - merged: repo.mergedPullRequests.totalCount, + open: repo.pullRequests?.totalCount || 0, + closed: repo.closedPullRequests?.totalCount || 0, + merged: repo.mergedPullRequests?.totalCount || 0, }, commits: { total: repo.defaultBranchRef?.target?.history?.totalCount || 0, lastMonth: repo.defaultBranchRef?.target?.historyLastMonth?.totalCount || 0, }, + beginnerIssues: repo.beginnerIssues, }; } catch (error) { console.error('Error fetching repository stats:', error); - throw new Error(`Failed to fetch repository stats for ${owner}/${name}`); + + // If GraphQL fails completely, try the minimal fallback + if (error instanceof Error && (error.message.includes('timeout') || error.message.includes('not found'))) { + throw error; // Re-throw specific errors + } + + throw new Error(`Failed to fetch repository stats for ${owner}/${name}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } @@ -478,6 +604,7 @@ export class GitHubService { name: repo.licenseInfo.name, key: repo.licenseInfo.key, } : null, + beginnerIssues: repo.beginnerIssues, })), totalCount: search.repositoryCount, hasNextPage: search.pageInfo.hasNextPage, @@ -817,38 +944,224 @@ export class GitHubService { */ static async getAdvancedAnalytics(owner: string, name: string): Promise { try { - // Get basic repository data + // Get basic repository stats with enhanced error handling const basicStats = await this.getRepositoryStats(owner, name); - // Get advanced data in parallel - const [historical, contributors, technologyStack] = await Promise.all([ - this.getHistoricalData(owner, name), - this.getContributorAnalysis(owner, name), - this.getTechnologyStack(owner, name) + // Fetch additional data with individual error handling + const [historicalData, contributorData, technologyStack, riskAssessment] = await Promise.allSettled([ + this.getHistoricalData(owner, name).catch(error => { + console.warn('Failed to fetch historical data:', error); + return []; + }), + this.getContributorAnalysis(owner, name).catch(error => { + console.warn('Failed to fetch contributor analysis:', error); + return []; + }), + this.getTechnologyStack(owner, name).catch(error => { + console.warn('Failed to fetch technology stack:', error); + return { languages: [], dependencies: [], frameworks: [] }; + }), + this.getRiskAssessment(owner, name, []).catch(error => { + console.warn('Failed to fetch risk assessment:', error); + return { + busFactor: { score: 0, level: 'medium' as const, topContributors: 0, description: 'Unable to assess' }, + maintenanceStatus: { score: 0, level: 'moderate' as const, lastCommit: '', avgCommitsPerMonth: 0, description: 'Unable to assess' }, + communityHealth: { score: 0, level: 'moderate' as const, factors: [] } + }; + }) ]); - // Get risk assessment (depends on contributors data) - const riskAssessment = await this.getRiskAssessment(owner, name, contributors); - - // Calculate trends - const trends = { - starsGrowth: calculateGrowthRate(historical, 'stars'), - forksGrowth: calculateGrowthRate(historical, 'forks'), - contributorsGrowth: contributors.length > 0 ? 5 : 0, // Simplified - commitActivity: calculateGrowthRate(historical, 'commits') + // Extract results with fallbacks + const historical = historicalData.status === 'fulfilled' ? historicalData.value : []; + const contributors = contributorData.status === 'fulfilled' ? contributorData.value : []; + const techStack = technologyStack.status === 'fulfilled' ? technologyStack.value : { languages: [], dependencies: [], frameworks: [] }; + const risk = riskAssessment.status === 'fulfilled' ? riskAssessment.value : { + busFactor: { score: 0, level: 'medium' as const, topContributors: 0, description: 'Unable to assess' }, + maintenanceStatus: { score: 0, level: 'moderate' as const, lastCommit: '', avgCommitsPerMonth: 0, description: 'Unable to assess' }, + communityHealth: { score: 0, level: 'moderate' as const, factors: [] } }; + // Calculate trends with fallbacks + const trends = this.calculateTrends(historical); + return { repository: basicStats.repository, + contributors: contributors, + releases: basicStats.releases, + issues: basicStats.issues, + pullRequests: basicStats.pullRequests, + commits: basicStats.commits, + beginnerIssues: basicStats.beginnerIssues, historical, - contributors, - technologyStack, - riskAssessment, + technologyStack: techStack, + riskAssessment: risk, trends }; } catch (error) { console.error('Error fetching advanced analytics:', error); - throw new Error(`Failed to fetch advanced analytics for ${owner}/${name}`); + + // If basic stats fail, try to provide minimal data + try { + const minimalStats = await this.getMinimalRepositoryData(owner, name); + return { + repository: minimalStats, + contributors: [], + releases: 0, + issues: { open: 0, closed: 0 }, + pullRequests: { open: 0, closed: 0, merged: 0 }, + commits: { total: 0, lastMonth: 0 }, + beginnerIssues: undefined, + historical: [], + technologyStack: { languages: [], dependencies: [], frameworks: [] }, + riskAssessment: { + busFactor: { score: 0, level: 'medium', topContributors: 0, description: 'Unable to assess' }, + maintenanceStatus: { score: 0, level: 'moderate', lastCommit: '', avgCommitsPerMonth: 0, description: 'Unable to assess' }, + communityHealth: { score: 0, level: 'moderate', factors: [] } + }, + trends: { starsGrowth: 0, forksGrowth: 0, contributorsGrowth: 0, commitActivity: 0 } + }; + } catch (minimalError) { + console.error('Even minimal data fetch failed:', minimalError); + throw new Error(`Failed to fetch any data for ${owner}/${name}. Repository may not exist or be inaccessible.`); + } + } + } + + /** + * Calculate trends from historical data with fallbacks + */ + private static calculateTrends(historical: HistoricalData[]): { starsGrowth: number; forksGrowth: number; contributorsGrowth: number; commitActivity: number } { + try { + if (historical.length < 2) { + return { starsGrowth: 0, forksGrowth: 0, contributorsGrowth: 0, commitActivity: 0 }; + } + + const recent = historical.slice(-3); // Last 3 months + const previous = historical.slice(-6, -3); // Previous 3 months + + const recentAvg = { + stars: recent.reduce((sum, d) => sum + d.stars, 0) / recent.length, + forks: recent.reduce((sum, d) => sum + d.forks, 0) / recent.length, + commits: recent.reduce((sum, d) => sum + d.commits, 0) / recent.length + }; + + const previousAvg = { + stars: previous.length > 0 ? previous.reduce((sum, d) => sum + d.stars, 0) / previous.length : 0, + forks: previous.length > 0 ? previous.reduce((sum, d) => sum + d.forks, 0) / previous.length : 0, + commits: previous.length > 0 ? previous.reduce((sum, d) => sum + d.commits, 0) / previous.length : 0 + }; + + return { + starsGrowth: previousAvg.stars > 0 ? Math.round(((recentAvg.stars - previousAvg.stars) / previousAvg.stars) * 100) : 0, + forksGrowth: previousAvg.forks > 0 ? Math.round(((recentAvg.forks - previousAvg.forks) / previousAvg.forks) * 100) : 0, + contributorsGrowth: 0, // Would need more data to calculate + commitActivity: previousAvg.commits > 0 ? Math.round(((recentAvg.commits - previousAvg.commits) / previousAvg.commits) * 100) : 0 + }; + } catch (error) { + console.warn('Error calculating trends:', error); + return { starsGrowth: 0, forksGrowth: 0, contributorsGrowth: 0, commitActivity: 0 }; + } + } + + /** + * Get minimal repository data as fallback + */ + private static async getMinimalRepositoryData(owner: string, name: string): Promise { + try { + // Try a minimal GraphQL query first + const minimalQuery = ` + query GetMinimalRepository($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + name + nameWithOwner + description + url + stargazerCount + forkCount + watchers { totalCount } + primaryLanguage { name } + createdAt + updatedAt + pushedAt + isArchived + isPrivate + owner { + login + ... on User { avatarUrl } + ... on Organization { avatarUrl } + } + } + } + `; + + const response = await getGitHubGraphQL()(minimalQuery, { owner, name }) as any; + const repo = response.repository; + + return { + id: repo.id, + name: repo.name, + fullName: repo.nameWithOwner, + description: repo.description, + url: repo.url, + stargazerCount: repo.stargazerCount || 0, + forkCount: repo.forkCount || 0, + watcherCount: repo.watchers?.totalCount || 0, + language: repo.primaryLanguage?.name || null, + topics: [], + createdAt: repo.createdAt, + updatedAt: repo.updatedAt, + pushedAt: repo.pushedAt, + isArchived: repo.isArchived || false, + isPrivate: repo.isPrivate || false, + owner: { + login: repo.owner.login, + type: repo.owner.__typename || 'User', + avatarUrl: repo.owner.avatarUrl || '', + }, + license: null, + beginnerIssues: undefined + }; + } catch (error) { + console.error('Minimal GraphQL query failed, trying REST API:', error); + + // Fallback to REST API + try { + const restClient = getGitHubRest(); + const repoResponse = await restClient.repos.get({ owner, repo: name }); + const repo = repoResponse.data; + + return { + id: repo.id.toString(), + name: repo.name, + fullName: repo.full_name, + description: repo.description, + url: repo.html_url, + stargazerCount: repo.stargazers_count || 0, + forkCount: repo.forks_count || 0, + watcherCount: repo.watchers_count || 0, + language: repo.language, + topics: repo.topics || [], + createdAt: repo.created_at, + updatedAt: repo.updated_at, + pushedAt: repo.pushed_at, + isArchived: repo.archived || false, + isPrivate: repo.private || false, + owner: { + login: repo.owner.login, + type: repo.owner.type, + avatarUrl: repo.owner.avatar_url || '', + }, + license: repo.license ? { + name: repo.license.name, + key: repo.license.key + } : null, + beginnerIssues: undefined + }; + } catch (restError) { + console.error('REST API fallback also failed:', restError); + throw new Error(`Repository ${owner}/${name} not found or inaccessible`); + } } } @@ -1243,16 +1556,16 @@ function detectFrameworks(languages: Array<{ name: string }>, dependencies: Arra return Array.from(frameworks); } -function calculateGrowthRate(historical: HistoricalData[], field: keyof HistoricalData): number { - if (historical.length < 2) return 0; +// function calculateGrowthRate(historical: HistoricalData[], field: keyof HistoricalData): number { +// if (historical.length < 2) return 0; - const recent = historical.slice(-3); // Last 3 months - const older = historical.slice(-6, -3); // Previous 3 months +// const recent = historical.slice(-3); // Last 3 months +// const older = historical.slice(-6, -3); // Previous 3 months - const recentAvg = recent.reduce((sum, item) => sum + (Number(item[field]) || 0), 0) / recent.length; - const olderAvg = older.reduce((sum, item) => sum + (Number(item[field]) || 0), 0) / older.length; +// const recentAvg = recent.reduce((sum, item) => sum + (Number(item[field]) || 0), 0) / recent.length; +// const olderAvg = older.reduce((sum, item) => sum + (Number(item[field]) || 0), 0) / older.length; - if (olderAvg === 0) return recentAvg > 0 ? 100 : 0; +// if (olderAvg === 0) return recentAvg > 0 ? 100 : 0; - return Math.round(((recentAvg - olderAvg) / olderAvg) * 100); -} \ No newline at end of file +// return Math.round(((recentAvg - olderAvg) / olderAvg) * 100); +// } \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..41850f8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function formatDate(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) +}