├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── api │ │ └── auth │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ └── wrapped │ │ ├── [username] │ │ └── page.tsx │ │ └── error.tsx └── lib │ ├── interfaces │ └── interfaces.ts │ └── utils.ts ├── tailwind.config.ts ├── tsconfig.json └── wrangler.toml /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kylejeong2/Github-Wrapped/c3a516427ca7b22a414028a760710e2101c3f2f2/.env.example -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:eslint-plugin-next-on-pages/recommended" 5 | ], 6 | "plugins": [ 7 | "eslint-plugin-next-on-pages" 8 | ] 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # wrangler files 39 | .wrangler 40 | .dev.vars 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Wrapped 2 | 3 | See your year in review on GitHub. 4 | 5 | By Kyle Jeong 6 | 7 | Follow me on [Twitter](https://twitter.com/kylejeong21) to support! -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler 2 | // by running `wrangler types --env-interface CloudflareEnv env.d.ts` 3 | 4 | interface CloudflareEnv { 5 | } 6 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev'; 2 | 3 | // Here we use the @cloudflare/next-on-pages next-dev module to allow us to use bindings during local development 4 | // (when running the application with `next dev`), for more information see: 5 | // https://github.com/cloudflare/next-on-pages/blob/main/internal-packages/next-dev/README.md 6 | if (process.env.NODE_ENV === 'development') { 7 | await setupDevPlatform(); 8 | } 9 | 10 | /** @type {import('next').NextConfig} */ 11 | const nextConfig = { 12 | images: { 13 | remotePatterns: [ 14 | { 15 | protocol: 'https', 16 | hostname: 'github.com', 17 | pathname: '/**', 18 | }, 19 | { 20 | protocol: 'https', 21 | hostname: 'avatars.githubusercontent.com', 22 | pathname: '/**', 23 | }, 24 | ], 25 | }, 26 | env: { 27 | OPENAI_API_KEY: process.env.OPENAI_API_KEY, 28 | GITHUB_TOKEN: process.env.GITHUB_TOKEN, 29 | }, 30 | experimental: {}, 31 | }; 32 | 33 | export default nextConfig; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-wrapped", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "pages:build": "npx @cloudflare/next-on-pages", 11 | "preview": "npm run pages:build && wrangler pages dev", 12 | "deploy": "npm run pages:build && wrangler pages deploy", 13 | "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts" 14 | }, 15 | "dependencies": { 16 | "@octokit/rest": "^21.0.2", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "framer-motion": "^11.13.1", 20 | "html2canvas": "^1.4.1", 21 | "lucide-react": "^0.468.0", 22 | "next": "14.2.5", 23 | "octokit": "^4.0.2", 24 | "openai": "^4.76.0", 25 | "react": "^18", 26 | "react-dom": "^18", 27 | "react-github-calendar": "^4.5.1", 28 | "react-icons": "^5.4.0", 29 | "tailwind-merge": "^2.5.5", 30 | "tailwindcss-animate": "^1.0.7" 31 | }, 32 | "devDependencies": { 33 | "@cloudflare/next-on-pages": "^1.13.6", 34 | "@cloudflare/workers-types": "^4.20241205.0", 35 | "@types/node": "^20", 36 | "@types/react": "^18", 37 | "@types/react-dom": "^18", 38 | "eslint": "^8", 39 | "eslint-config-next": "14.2.5", 40 | "eslint-plugin-next-on-pages": "^1.13.6", 41 | "postcss": "^8", 42 | "tailwindcss": "^3.4.1", 43 | "typescript": "^5", 44 | "vercel": "^39.1.3", 45 | "wrangler": "^3.93.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { OpenAI } from "openai"; 3 | import { Octokit } from "@octokit/rest"; 4 | import { GraphQLResponse, UserStats } from "@/lib/interfaces/interfaces"; 5 | 6 | export const runtime = 'edge'; 7 | 8 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY; 9 | const GITHUB_TOKEN = process.env.GITHUB_TOKEN; 10 | 11 | if (!OPENAI_API_KEY) { 12 | throw new Error('Missing OPENAI_API_KEY environment variable'); 13 | } 14 | 15 | if (!GITHUB_TOKEN) { 16 | throw new Error('Missing GITHUB_TOKEN environment variable'); 17 | } 18 | 19 | const octokit = new Octokit({ 20 | auth: GITHUB_TOKEN 21 | }); 22 | 23 | const openai = new OpenAI({ 24 | apiKey: OPENAI_API_KEY, 25 | dangerouslyAllowBrowser: true 26 | }); 27 | 28 | interface PullRequest { 29 | title: string; 30 | state: string; 31 | repo: string; 32 | date: string; 33 | url: string; 34 | merged: boolean; 35 | additions: number; 36 | deletions: number; 37 | } 38 | 39 | async function generateAIAnalysis(userData: { stats: UserStats }) { 40 | const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; 41 | const monthlyCommits = userData.stats.monthlyCommits; 42 | const grindingMonth = userData.stats.grindingMonth; 43 | 44 | // Pick a random month that's not the grinding month 45 | const availableMonths = Object.entries(monthlyCommits) 46 | .filter(([month]) => month !== grindingMonth && monthlyCommits[month] > 0) 47 | .map(([month]) => month); 48 | const randomMonth = availableMonths[Math.floor(Math.random() * availableMonths.length)]; 49 | 50 | const prompt = `As a witty developer advocate, create 2 short, impactful observations about this GitHub user's coding activity. Be creative and fun! 51 | Here's their data for 2024: 52 | - Total Repos: ${userData.stats.totalRepos} 53 | - Total Stars: ${userData.stats.totalStars} 54 | - Top Languages: ${userData.stats.topLanguages.map(([lang, count]) => `${lang} (${count} repos)`).join(', ')} 55 | - Total Commits: ${userData.stats.totalCommits} 56 | - Most Active Repository: ${userData.stats.mostActiveRepo} 57 | - Longest Streak: ${userData.stats.longestStreak} days 58 | - Grinding Month (${monthNames[parseInt(grindingMonth!) - 1]}): ${monthlyCommits[grindingMonth!]} commits 59 | 60 | Also, describe their coding style in ${monthNames[parseInt(randomMonth) - 1]} with exactly 3 powerful, single-word adjectives. 61 | 62 | Format the response like this: 63 | [ANALYSIS] 64 | • First witty observation with an emoji 65 | • Second witty observation with an emoji 66 | 67 | [MONTH_ANALYSIS] 68 | WORD1 69 | WORD2 70 | WORD3`; 71 | 72 | const response = await openai.chat.completions.create({ 73 | model: "gpt-4o-mini", 74 | messages: [{ role: "user", content: prompt }], 75 | temperature: 0.9, 76 | max_tokens: 250, 77 | }); 78 | 79 | return { 80 | analysis: response.choices[0].message.content, 81 | randomMonth, 82 | }; 83 | } 84 | 85 | export async function POST(request: Request) { 86 | try { 87 | const { username } = await request.json() as { username: string }; 88 | 89 | if (!username) { 90 | return NextResponse.json( 91 | { error: "Username is required" }, 92 | { status: 400 } 93 | ); 94 | } 95 | 96 | // Check if user exists and has starred the repo 97 | const [userResponse, starredResponse] = await Promise.all([ 98 | octokit.users.getByUsername({ username }), 99 | octokit.activity.checkRepoIsStarredByAuthenticatedUser({ 100 | owner: "Kylejeong2", 101 | repo: "Github-Wrapped", 102 | username, 103 | }).catch(() => ({ status: 404 })), 104 | ]); 105 | 106 | if (starredResponse.status === 404) { 107 | return NextResponse.json( 108 | { error: "NOT_STARRED" }, 109 | { status: 403 } 110 | ); 111 | } 112 | 113 | // Get user's contribution data using GraphQL 114 | const query = ` 115 | query($username: String!) { 116 | user(login: $username) { 117 | contributionsCollection { 118 | contributionCalendar { 119 | totalContributions 120 | weeks { 121 | contributionDays { 122 | contributionCount 123 | date 124 | } 125 | } 126 | } 127 | pullRequestContributions(first: 100, orderBy: {direction: DESC}) { 128 | totalCount 129 | nodes { 130 | pullRequest { 131 | title 132 | state 133 | url 134 | createdAt 135 | merged 136 | additions 137 | deletions 138 | repository { 139 | name 140 | nameWithOwner 141 | url 142 | } 143 | } 144 | } 145 | } 146 | pullRequestReviewContributions(first: 100, orderBy: {direction: DESC}) { 147 | totalCount 148 | nodes { 149 | pullRequest { 150 | title 151 | state 152 | url 153 | createdAt 154 | merged 155 | repository { 156 | name 157 | nameWithOwner 158 | } 159 | } 160 | } 161 | } 162 | } 163 | repositories(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) { 164 | nodes { 165 | name 166 | description 167 | url 168 | stargazerCount 169 | primaryLanguage { 170 | name 171 | } 172 | defaultBranchRef { 173 | target { 174 | ... on Commit { 175 | history(first: 1) { 176 | totalCount 177 | } 178 | } 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | `; 186 | 187 | const graphqlResponse = await octokit.graphql(query, { username }) as GraphQLResponse; 188 | const userData = graphqlResponse.user; 189 | 190 | // Process contribution data 191 | const contributionDays = userData.contributionsCollection.contributionCalendar.weeks 192 | .flatMap((week: any) => week.contributionDays) 193 | .filter((day: any) => new Date(day.date) >= new Date('2024-01-01')); 194 | 195 | const monthlyCommits: { [key: string]: number } = {}; 196 | contributionDays.forEach((day: any) => { 197 | const month = new Date(day.date).getMonth() + 1; 198 | const monthKey = month.toString().padStart(2, '0'); 199 | monthlyCommits[monthKey] = (monthlyCommits[monthKey] || 0) + day.contributionCount; 200 | }); 201 | 202 | // Find the grinding month 203 | const grindingMonth = Object.entries(monthlyCommits) 204 | .sort(([, a], [, b]) => b - a)[0]?.[0]; 205 | 206 | // Process the data 207 | const processedData = { 208 | profile: userResponse.data, 209 | stats: { 210 | totalRepos: userData.repositories.nodes.length, 211 | totalStars: userData.repositories.nodes.reduce((acc: number, repo: any) => acc + repo.stargazerCount, 0), 212 | topLanguages: processTopLanguages(userData.repositories.nodes), 213 | commitActivity: Object.fromEntries( 214 | contributionDays.map((day: any) => [day.date, day.contributionCount]) 215 | ), 216 | totalCommits: userData.contributionsCollection.contributionCalendar.totalContributions, 217 | mostActiveRepo: getMostActiveRepo(userData.repositories.nodes), 218 | longestStreak: calculateStreak(contributionDays), 219 | currentStreak: calculateCurrentStreak(contributionDays), 220 | pullRequests: { 221 | created: userData.contributionsCollection.pullRequestContributions.totalCount, 222 | reviewed: userData.contributionsCollection.pullRequestReviewContributions.totalCount, 223 | recentPRs: userData.contributionsCollection.pullRequestContributions.nodes 224 | .filter((node: any) => new Date(node.pullRequest.createdAt) >= new Date('2024-01-01')) 225 | .map((node: any) => ({ 226 | title: node.pullRequest.title, 227 | state: node.pullRequest.state.toLowerCase(), 228 | repo: node.pullRequest.repository.nameWithOwner, 229 | date: new Date(node.pullRequest.createdAt).toISOString().split('T')[0], 230 | url: node.pullRequest.url, 231 | merged: node.pullRequest.merged, 232 | additions: node.pullRequest.additions || 0, 233 | deletions: node.pullRequest.deletions || 0 234 | } as PullRequest)) 235 | .slice(0, 10), 236 | recentReviews: userData.contributionsCollection.pullRequestReviewContributions.nodes 237 | .filter((node: any) => new Date(node.pullRequest.createdAt) >= new Date('2024-01-01')) 238 | .map((node: any) => ({ 239 | title: node.pullRequest.title, 240 | state: node.pullRequest.state.toLowerCase(), 241 | repo: node.pullRequest.repository.nameWithOwner, 242 | date: new Date(node.pullRequest.createdAt).toISOString().split('T')[0], 243 | url: node.pullRequest.url, 244 | merged: node.pullRequest.merged 245 | })) 246 | .slice(0, 5), 247 | stats: { 248 | totalChanges: userData.contributionsCollection.pullRequestContributions.nodes 249 | .reduce((acc: number, node: any) => 250 | acc + (node.pullRequest.additions || 0) + (node.pullRequest.deletions || 0), 0), 251 | mergedPRs: userData.contributionsCollection.pullRequestContributions.nodes 252 | .filter((node: any) => node.pullRequest.merged).length, 253 | averageChangesPerPR: Math.round( 254 | userData.contributionsCollection.pullRequestContributions.nodes 255 | .reduce((acc: number, node: any) => 256 | acc + (node.pullRequest.additions || 0) + (node.pullRequest.deletions || 0), 0) / 257 | Math.max(1, userData.contributionsCollection.pullRequestContributions.nodes.length) 258 | ) 259 | } 260 | }, 261 | recentRepos: userData.repositories.nodes.slice(0, 6).map((repo: any) => ({ 262 | name: repo.name, 263 | stars: repo.stargazerCount, 264 | language: repo.primaryLanguage?.name, 265 | description: repo.description, 266 | url: repo.url, 267 | })), 268 | monthlyCommits, 269 | grindingMonth, 270 | } as UserStats, 271 | }; 272 | 273 | // Generate AI analysis 274 | const aiResult = await generateAIAnalysis(processedData); 275 | processedData.stats.aiAnalysis = aiResult.analysis || ""; 276 | processedData.stats.randomMonthAnalysis = { 277 | month: aiResult.randomMonth, 278 | words: aiResult.analysis?.split('[MONTH_ANALYSIS]')[1] 279 | ?.trim() 280 | ?.split('\n') 281 | ?.filter(Boolean) 282 | ?.map(w => w.trim()) || [], 283 | }; 284 | 285 | return NextResponse.json(processedData); 286 | } catch (error: any) { 287 | console.error("Error:", error); 288 | return NextResponse.json( 289 | { error: error.message || "Something went wrong" }, 290 | { status: 500 } 291 | ); 292 | } 293 | } 294 | 295 | function processTopLanguages(repos: any[]): [string, number][] { 296 | const languages = repos.reduce((acc: { [key: string]: number }, repo: any) => { 297 | if (repo.primaryLanguage?.name) { 298 | acc[repo.primaryLanguage.name] = (acc[repo.primaryLanguage.name] || 0) + 1; 299 | } 300 | return acc; 301 | }, {}); 302 | 303 | return Object.entries(languages) 304 | .sort(([, a], [, b]) => b - a) 305 | .slice(0, 5); 306 | } 307 | 308 | function getMostActiveRepo(repos: any[]): string { 309 | return repos.reduce((max: any, repo: any) => { 310 | const commits = repo.defaultBranchRef?.target?.history?.totalCount || 0; 311 | return commits > (max.commits || 0) ? { name: repo.name, commits } : max; 312 | }, {}).name || "N/A"; 313 | } 314 | 315 | function calculateStreak(days: any[]): number { 316 | let currentStreak = 0; 317 | let maxStreak = 0; 318 | 319 | for (let i = 0; i < days.length; i++) { 320 | if (days[i].contributionCount > 0) { 321 | currentStreak++; 322 | maxStreak = Math.max(maxStreak, currentStreak); 323 | } else { 324 | currentStreak = 0; 325 | } 326 | } 327 | 328 | return maxStreak; 329 | } 330 | 331 | function calculateCurrentStreak(days: any[]): number { 332 | let streak = 0; 333 | const today = new Date().toISOString().split('T')[0]; 334 | 335 | for (let i = days.length - 1; i >= 0; i--) { 336 | if (days[i].date === today && days[i].contributionCount === 0) break; 337 | if (days[i].contributionCount > 0) { 338 | streak++; 339 | } else { 340 | break; 341 | } 342 | } 343 | 344 | return streak; 345 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kylejeong2/Github-Wrapped/c3a516427ca7b22a414028a760710e2101c3f2f2/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | @layer utilities { 20 | .text-balance { 21 | text-wrap: balance; 22 | } 23 | } 24 | 25 | @layer base { 26 | :root { 27 | --background: 0 0% 100%; 28 | --foreground: 0 0% 3.9%; 29 | --card: 0 0% 100%; 30 | --card-foreground: 0 0% 3.9%; 31 | --popover: 0 0% 100%; 32 | --popover-foreground: 0 0% 3.9%; 33 | --primary: 0 0% 9%; 34 | --primary-foreground: 0 0% 98%; 35 | --secondary: 0 0% 96.1%; 36 | --secondary-foreground: 0 0% 9%; 37 | --muted: 0 0% 96.1%; 38 | --muted-foreground: 0 0% 45.1%; 39 | --accent: 0 0% 96.1%; 40 | --accent-foreground: 0 0% 9%; 41 | --destructive: 0 84.2% 60.2%; 42 | --destructive-foreground: 0 0% 98%; 43 | --border: 0 0% 89.8%; 44 | --input: 0 0% 89.8%; 45 | --ring: 0 0% 3.9%; 46 | --chart-1: 12 76% 61%; 47 | --chart-2: 173 58% 39%; 48 | --chart-3: 197 37% 24%; 49 | --chart-4: 43 74% 66%; 50 | --chart-5: 27 87% 67%; 51 | --radius: 0.5rem; 52 | } 53 | .dark { 54 | --background: 0 0% 3.9%; 55 | --foreground: 0 0% 98%; 56 | --card: 0 0% 3.9%; 57 | --card-foreground: 0 0% 98%; 58 | --popover: 0 0% 3.9%; 59 | --popover-foreground: 0 0% 98%; 60 | --primary: 0 0% 98%; 61 | --primary-foreground: 0 0% 9%; 62 | --secondary: 0 0% 14.9%; 63 | --secondary-foreground: 0 0% 98%; 64 | --muted: 0 0% 14.9%; 65 | --muted-foreground: 0 0% 63.9%; 66 | --accent: 0 0% 14.9%; 67 | --accent-foreground: 0 0% 98%; 68 | --destructive: 0 62.8% 30.6%; 69 | --destructive-foreground: 0 0% 98%; 70 | --border: 0 0% 14.9%; 71 | --input: 0 0% 14.9%; 72 | --ring: 0 0% 83.1%; 73 | --chart-1: 220 70% 50%; 74 | --chart-2: 160 60% 45%; 75 | --chart-3: 30 80% 55%; 76 | --chart-4: 280 65% 60%; 77 | --chart-5: 340 75% 55%; 78 | } 79 | } 80 | 81 | @layer base { 82 | * { 83 | @apply border-border; 84 | } 85 | body { 86 | @apply bg-background text-foreground; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "GitHub Wrapped", 9 | description: "See your year in review on GitHub.", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export const runtime = "edge"; 2 | 3 | export default function NotFound() { 4 | return ( 5 | <> 6 | 404: This page could not be found. 7 |
8 |
9 |