├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .gitattributes ├── .gitignore ├── .mvn │ └── wrapper │ │ └── maven-wrapper.properties ├── Dockerfile ├── deployments │ └── application.yml ├── fly.toml ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── dev │ │ │ └── amitwani │ │ │ └── githubwrapped │ │ │ ├── GithubWrappedApplication.java │ │ │ ├── auth │ │ │ └── AuthFilter.java │ │ │ ├── controller │ │ │ ├── HealthController.java │ │ │ └── StatsController.java │ │ │ ├── dto │ │ │ ├── AllUserDTO.java │ │ │ ├── ResponseDTO.java │ │ │ ├── StatsDTO.java │ │ │ ├── TopUserDTO.java │ │ │ └── graphql │ │ │ │ ├── GitHubContributionStats.java │ │ │ │ ├── GitHubPinnedItems.java │ │ │ │ ├── GitHubRepositoryStats.java │ │ │ │ └── GraphQLRequest.java │ │ │ ├── model │ │ │ ├── GitHubStats.java │ │ │ └── GitHubUser.java │ │ │ ├── repository │ │ │ ├── GitHubStatsRepository.java │ │ │ └── GitHubUserRepository.java │ │ │ └── service │ │ │ ├── GitHubService.java │ │ │ └── StatsService.java │ └── resources │ │ ├── application-local.yml │ │ └── application.yml │ └── test │ └── java │ └── dev │ └── amitwani │ └── githubwrapped │ └── GithubWrappedApplicationTests.java └── frontend ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── [username] │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── about │ ├── layout.tsx │ └── page.tsx ├── actions │ ├── all-user-action.ts │ ├── stats-action.ts │ └── top-user-action.ts ├── api │ ├── [username] │ │ └── og │ │ │ └── route.js │ └── ai │ │ └── route.ts ├── favicon.ico ├── fonts │ └── GeistMonoVF.woff ├── globals.css ├── layout.tsx ├── page.tsx ├── posthog.tsx └── sitemap.ts ├── components.json ├── components ├── ai-analysis.tsx ├── contribution-breakdown.tsx ├── donate-button.tsx ├── footer.tsx ├── navbar.tsx ├── profile-header.tsx ├── save-image.tsx ├── social-share.tsx └── ui │ ├── button.tsx │ ├── card.tsx │ ├── chart.tsx │ ├── input.tsx │ ├── toaster.tsx │ └── wavy-background.tsx ├── lib ├── analytics.ts └── utils.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── github-wrapped-og.png └── robots.txt ├── tailwind.config.ts ├── tsconfig.json ├── types ├── ai.ts ├── stats.ts └── topUser.ts └── utils └── op.ts /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Amit Wani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Wrapped 2024 2 | 3 | Your personalized year in review for GitHub contributions and coding activity. View your GitHub stats, contributions, and coding journey for 2024 in a beautiful, shareable format. 4 | 5 | ![GitHub Wrapped 2024](https://githubwrapped.xyz/github-wrapped-og.png) 6 | 7 | ## Live Demo - [Click here](https://githubwrapped.xyz) 8 | 9 | ## Features 10 | 11 | - 📊 Comprehensive GitHub statistics for 2024 12 | - 📈 Contribution patterns visualization 13 | - 🔤 Most used programming languages 14 | - 🌟 Top repositories showcase 15 | - 📊 Contribution breakdown analysis 16 | - 🎯 Weekend activity tracking 17 | - 📱 Social sharing capabilities 18 | - 🖼️ Shareable OG images 19 | 20 | ## Tech Stack 21 | 22 | ### Frontend 23 | 24 | - Next.js 14 25 | - TypeScript 26 | - Tailwind CSS 27 | - Shadcn UI Components 28 | - OpenPanel for analytics 29 | - Server Actions for API calls 30 | 31 | ### Backend 32 | 33 | - Spring Boot 3.3 34 | - Java 21 35 | - MongoDB 36 | - GitHub API Integration 37 | - Resilience4j for circuit breaking 38 | - Maven for dependency management 39 | 40 | ## Getting Started 41 | 42 | ### Prerequisites 43 | 44 | - Node.js (Latest LTS version) 45 | - Java 21 46 | - MongoDB 47 | - GitHub API Token 48 | - pnpm (recommended) or npm 49 | 50 | ### Frontend Setup 51 | 52 | 1. Clone the repository 53 | 54 | ```bash 55 | git clone https://github.com/mtwn105/GitHubWrapped.git 56 | cd frontend 57 | ``` 58 | 59 | 2. Install dependencies 60 | 61 | ```bash 62 | pnpm install 63 | ``` 64 | 65 | 3. Set up environment variables 66 | 67 | ```bash 68 | # Create .env.local file 69 | BACKEND_URL=http://localhost:9009 70 | BACKEND_AUTH_TOKEN=your_auth_token 71 | NEXT_PUBLIC_APP_URL=http://localhost:3000 72 | NEXT_PUBLIC_OPENPANEL_CLIENTID=your_openpanel_client_id 73 | OPENPANEL_CLIENTID=your_openpanel_client_id 74 | OPENPANEL_CLIENT_SECRET=your_openpanel_client_secret 75 | ``` 76 | 77 | 4. Run development server 78 | 79 | ```bash 80 | pnpm dev 81 | ``` 82 | 83 | The frontend will be available at `http://localhost:3000` 84 | 85 | ### Backend Setup 86 | 87 | 1. Navigate to backend directory 88 | 89 | ```bash 90 | cd backend 91 | ``` 92 | 93 | 2. Configure application.yml 94 | 95 | ```yaml 96 | server: 97 | port: 9009 98 | 99 | spring: 100 | data: 101 | mongodb: 102 | uri: mongodb://localhost:27017/githubwrapped 103 | 104 | auth: 105 | token: your_auth_token 106 | 107 | github: 108 | graphql: 109 | url: https://api.github.com/graphql 110 | username: your_github_username 111 | token: your_github_token 112 | ``` 113 | 114 | 3. Run the application 115 | 116 | ```bash 117 | ./mvnw spring-boot:run 118 | ``` 119 | 120 | The backend will be available at `http://localhost:9009` 121 | 122 | ## Project Structure 123 | 124 | ``` 125 | project-root/ 126 | ├── frontend/ 127 | │ ├── app/ 128 | │ │ ├── actions/ 129 | │ │ ├── components/ 130 | │ │ └── [username]/ 131 | │ ├── components/ 132 | │ ├── types/ 133 | │ └── public/ 134 | └── backend/ 135 | ├── src/ 136 | │ ├── main/ 137 | │ │ ├── java/ 138 | │ │ └── resources/ 139 | │ └── test/ 140 | └── pom.xml 141 | ``` 142 | 143 | ## API Endpoints 144 | 145 | ### Stats 146 | 147 | ``` 148 | GET /api/stats/{username} - Get user's GitHub stats 149 | POST /api/stats/{username} - Generate user's GitHub stats 150 | GET /api/stats/top - Get top GitHub users 151 | GET /api/stats/all - Get all GitHub users 152 | ``` 153 | 154 | ### Health 155 | 156 | ``` 157 | GET /api/health - Check API health status 158 | ``` 159 | 160 | ## Deployment 161 | 162 | ### Frontend 163 | 164 | The application is optimized for deployment on Vercel: 165 | 166 | 1. Connect your GitHub repository to Vercel 167 | 2. Configure environment variables 168 | 3. Deploy with `vercel deploy` 169 | 170 | ### Backend 171 | 172 | The backend includes Fly.io configuration: 173 | 174 | 1. Install Fly.io CLI 175 | 2. Configure secrets: 176 | 177 | ```bash 178 | flyctl secrets set MONGODB_URI=your_mongodb_uri 179 | flyctl secrets set AUTH_TOKEN=your_auth_token 180 | flyctl secrets set GITHUB_TOKEN=your_github_token 181 | ``` 182 | 183 | 3. Deploy: 184 | 185 | ```bash 186 | flyctl deploy 187 | ``` 188 | 189 | ## Contributing 190 | 191 | Contributions are welcome! Please feel free to submit a Pull Request. 192 | 193 | 1. Fork the repository 194 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 195 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 196 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 197 | 5. Open a Pull Request 198 | 199 | ## License 200 | 201 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 202 | 203 | ## Author 204 | 205 | Created by [Amit Wani](https://github.com/mtwn105) 206 | 207 | - Twitter: [@mtwn105](https://x.com/mtwn105) 208 | - GitHub: [@mtwn105](https://github.com/mtwn105) 209 | 210 | ## Acknowledgments 211 | 212 | - Built with [Next.js](https://nextjs.org/) and [Spring Boot](https://spring.io/projects/spring-boot) 213 | - UI components from [shadcn/ui](https://ui.shadcn.com/) 214 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | !**\.mvn\wrapper\maven-wrapper.jar 3 | 4 | ### STS ### 5 | **\.apt_generated 6 | **\.classpath 7 | **\.factorypath 8 | **\.project 9 | **\.settings 10 | **\.springBeans 11 | **\.sts4-cache 12 | 13 | ### IntelliJ IDEA ### 14 | **\.idea 15 | **\*.iws 16 | **\*.iml 17 | **\*.ipr 18 | 19 | ### NetBeans ### 20 | nbproject\private 21 | nbbuild 22 | dist 23 | nbdist 24 | .nb-gradle 25 | **\build 26 | !**\**\src\main\**\build 27 | !**\**\src\test\**\build 28 | 29 | ### VS Code ### 30 | **\.vscode 31 | 32 | # flyctl launch added from .idea\.gitignore 33 | # Default ignored files 34 | .idea\shelf 35 | .idea\workspace.xml 36 | fly.toml 37 | -------------------------------------------------------------------------------- /backend/.gitattributes: -------------------------------------------------------------------------------- 1 | /mvnw text eol=lf 2 | *.cmd text eol=crlf 3 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | !**/src/main/resources/application.yml 36 | -------------------------------------------------------------------------------- /backend/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Eclipse Temurin as the base image for Java 21 2 | FROM eclipse-temurin:21-jre-alpine 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy the application JAR file into the container 8 | COPY target/githubwrapped-0.0.1-SNAPSHOT.jar app.jar 9 | 10 | # Copy the application configuration file into the container 11 | COPY deployments/application.yml application.yml 12 | 13 | # Expose the port that the application will run on 14 | EXPOSE 9009 15 | 16 | # Run the application 17 | ENTRYPOINT ["java", "-jar", "app.jar"] -------------------------------------------------------------------------------- /backend/deployments/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 9009 3 | 4 | spring: 5 | application: 6 | name: GitHub-Wrapped 7 | data: 8 | mongodb: 9 | uri: ${MONGODB_URI} #Z1GoJiJCWOs95qO6 10 | 11 | auth: 12 | token: ${AUTH_TOKEN} 13 | 14 | github: 15 | graphql: 16 | url: ${GITHUB_GRAPHQL_URL} 17 | username: ${GITHUB_USERNAME} 18 | token: ${GITHUB_TOKEN} -------------------------------------------------------------------------------- /backend/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for gh-wrapped-backend on 2024-12-15T19:05:47+05:30 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'gh-wrapped-backend' 7 | primary_region = 'iad' 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 9009 13 | force_https = true 14 | auto_stop_machines = 'off' 15 | auto_start_machines = true 16 | min_machines_running = 1 17 | processes = ['app'] 18 | [[http_service.checks]] 19 | grace_period = "60s" 20 | interval = "30s" 21 | method = "GET" 22 | timeout = "15s" 23 | path = "/api/health" 24 | 25 | [[vm]] 26 | size = 'shared-cpu-1x' 27 | memory = "1gb" 28 | 29 | -------------------------------------------------------------------------------- /backend/mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /backend/mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /backend/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.3.6 9 | 10 | 11 | dev.amitwani 12 | githubwrapped 13 | 0.0.1-SNAPSHOT 14 | githubwrapped 15 | GitHub Wrapped Backend 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 21 31 | 2023.0.4 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-data-mongodb 37 | 38 | 39 | org.springframework.boot 40 | spring-boot-starter-web 41 | 42 | 43 | org.springframework.cloud 44 | spring-cloud-starter-circuitbreaker-resilience4j 45 | 46 | 47 | org.springframework.boot 48 | spring-boot-devtools 49 | runtime 50 | true 51 | 52 | 53 | org.springframework.boot 54 | spring-boot-starter-test 55 | test 56 | 57 | 58 | org.kohsuke 59 | github-api 60 | 1.326 61 | 62 | 63 | org.projectlombok 64 | lombok 65 | 66 | 67 | 68 | 69 | 70 | org.springframework.cloud 71 | spring-cloud-dependencies 72 | ${spring-cloud.version} 73 | pom 74 | import 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | org.springframework.boot 83 | spring-boot-maven-plugin 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/GithubWrappedApplication.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.cache.annotation.EnableCaching; 6 | 7 | @SpringBootApplication 8 | @EnableCaching 9 | public class GithubWrappedApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(GithubWrappedApplication.class, args); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/auth/AuthFilter.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.auth; 2 | 3 | import jakarta.servlet.FilterChain; 4 | import jakarta.servlet.ServletException; 5 | import jakarta.servlet.annotation.WebFilter; 6 | import jakarta.servlet.http.HttpServletRequest; 7 | import jakarta.servlet.http.HttpServletResponse; 8 | import org.slf4j.Logger; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.web.filter.OncePerRequestFilter; 12 | 13 | import java.io.IOException; 14 | import java.time.Instant; 15 | 16 | @WebFilter 17 | @Component 18 | public class AuthFilter extends OncePerRequestFilter { 19 | 20 | private static final Logger LOGGER = org.slf4j.LoggerFactory.getLogger(AuthFilter.class); 21 | 22 | @Value("${auth.token}") 23 | private String token; 24 | 25 | @Override 26 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { 27 | // Validate requests 28 | if (request.getRequestURI().contains("/actuator") || request.getRequestURI().contains("/health")) { 29 | filterChain.doFilter(request, response); 30 | } else if (request.getHeader("Authorization") != null && request.getHeader("Authorization").equals(token)) { 31 | Instant start = Instant.now(); 32 | filterChain.doFilter(request, response); 33 | Instant end = Instant.now(); 34 | LOGGER.info("Request {} {} took {} ms", request.getMethod(), request.getRequestURI(), end.toEpochMilli() - start.toEpochMilli()); 35 | } else { 36 | LOGGER.warn("Unauthorized request {} from {}", request.getRequestURI(), request.getRemoteAddr()); 37 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 38 | response.setHeader("Content-Type", "application/json"); 39 | response.getWriter().write("{\"message\": \"Unauthorized\"}"); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/controller/HealthController.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.controller; 2 | 3 | import dev.amitwani.githubwrapped.dto.ResponseDTO; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.availability.ApplicationAvailability; 8 | import org.springframework.boot.availability.LivenessState; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | @RestController 15 | @RequestMapping("/api/health") 16 | public class HealthController { 17 | 18 | private static final Logger LOGGER = LoggerFactory.getLogger(HealthController.class); 19 | 20 | 21 | @Autowired 22 | ApplicationAvailability applicationAvailability; 23 | 24 | @GetMapping 25 | public ResponseEntity healthCheck() { 26 | if (applicationAvailability.getLivenessState().equals(LivenessState.CORRECT)) { 27 | return ResponseEntity.ok(new ResponseDTO("OK", null)); 28 | } else { 29 | LOGGER.error("Health check failed {}", applicationAvailability.getLivenessState()); 30 | return ResponseEntity.status(500).body(new ResponseDTO("NOT OK", null)); 31 | } 32 | } 33 | 34 | 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/controller/StatsController.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.controller; 2 | 3 | import dev.amitwani.githubwrapped.dto.AllUserDTO; 4 | import dev.amitwani.githubwrapped.dto.ResponseDTO; 5 | import dev.amitwani.githubwrapped.dto.StatsDTO; 6 | import dev.amitwani.githubwrapped.dto.TopUserDTO; 7 | import dev.amitwani.githubwrapped.service.StatsService; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.web.bind.annotation.*; 13 | 14 | import java.util.List; 15 | 16 | @RestController 17 | @RequestMapping("/api/stats") 18 | public class StatsController { 19 | 20 | private static final Logger LOGGER = LoggerFactory.getLogger(StatsController.class); 21 | 22 | @Autowired 23 | private StatsService statsService; 24 | 25 | @GetMapping("/{username}") 26 | public ResponseEntity getStats(@PathVariable String username) { 27 | LOGGER.info("Received request to fetch stats for user: {}", username); 28 | StatsDTO statsDTO = statsService.getStats(username); 29 | if (statsDTO == null) { 30 | return ResponseEntity.status(404).body(new ResponseDTO("User stats not found", null)); 31 | } 32 | return ResponseEntity.ok(new ResponseDTO("Stats fetched successfully", statsDTO)); 33 | } 34 | 35 | @PostMapping("/{username}") 36 | public ResponseEntity generateGitHubStats(@PathVariable String username) { 37 | LOGGER.info("Received request to generate stats for user: {}", username); 38 | return statsService.generateGitHubStats(username); 39 | } 40 | 41 | @GetMapping("/top") 42 | public ResponseEntity getTopUsers() { 43 | LOGGER.info("Received request to fetch top users"); 44 | List topUsers = statsService.getTopUsers(); 45 | return ResponseEntity.ok(new ResponseDTO("Top users fetched successfully", topUsers)); 46 | } 47 | 48 | @GetMapping("/all") 49 | public ResponseEntity getAllUsers() { 50 | LOGGER.info("Received request to fetch all users"); 51 | List allUserDTOS = statsService.getAllUsers(); 52 | return ResponseEntity.ok(new ResponseDTO("All users fetched successfully", allUserDTOS)); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/AllUserDTO.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.io.Serial; 8 | import java.io.Serializable; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | public class AllUserDTO implements Serializable { 14 | @Serial 15 | private static final long serialVersionUID = 1L; 16 | private String username; 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/ResponseDTO.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @AllArgsConstructor 9 | @NoArgsConstructor 10 | public class ResponseDTO { 11 | private String message; 12 | private Object data; 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/StatsDTO.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto; 2 | 3 | import dev.amitwani.githubwrapped.model.GitHubStats; 4 | import dev.amitwani.githubwrapped.model.GitHubUser; 5 | import lombok.Data; 6 | 7 | import java.io.Serial; 8 | import java.io.Serializable; 9 | 10 | @Data 11 | public class StatsDTO implements Serializable { 12 | 13 | @Serial 14 | private static final long serialVersionUID = 1L; 15 | 16 | private String username; 17 | private GitHubUser user; 18 | private GitHubStats stats; 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/TopUserDTO.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto; 2 | 3 | import lombok.Data; 4 | 5 | import java.io.Serial; 6 | import java.io.Serializable; 7 | 8 | @Data 9 | public class TopUserDTO implements Serializable { 10 | 11 | @Serial 12 | private static final long serialVersionUID = 1L; 13 | 14 | private String username; 15 | private String name; 16 | private String avatarUrl; 17 | private long totalContributions; 18 | private long totalCommits; 19 | private long totalIssuesClosed; 20 | private long totalPullRequestsClosed; 21 | private long totalStars; 22 | private long totalForks; 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/graphql/GitHubContributionStats.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto.graphql; 2 | 3 | import dev.amitwani.githubwrapped.model.GitHubStats; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class GitHubContributionStats { 8 | 9 | private DataNode data; 10 | 11 | @Data 12 | public static class ContributionsCollection { 13 | private int commits; 14 | private int issuesClosed; 15 | private int pullRequestsClosed; 16 | private GitHubStats.ContributionCalendar contributionCalendar; 17 | } 18 | 19 | @Data 20 | public static class DataNode { 21 | private User user; 22 | } 23 | 24 | @Data 25 | public static class User { 26 | private ContributionsCollection contributionsCollection; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/graphql/GitHubPinnedItems.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto.graphql; 2 | 3 | import lombok.Data; 4 | 5 | import java.util.List; 6 | 7 | @Data 8 | public class GitHubPinnedItems { 9 | 10 | private DataNode data; 11 | 12 | @Data 13 | public static class DataNode { 14 | private UserNode user; 15 | } 16 | 17 | @Data 18 | public static class UserNode { 19 | private PinnedItems pinnedItems; 20 | } 21 | 22 | @Data 23 | public static class PinnedItems { 24 | private List edges; 25 | } 26 | 27 | @Data 28 | public static class Edge { 29 | private Node node; 30 | } 31 | 32 | @Data 33 | public static class Node { 34 | private String name; 35 | private String description; 36 | private String url; 37 | private int stars; 38 | private int forkCount; 39 | private PrimaryLanguage primaryLanguage; 40 | } 41 | 42 | @Data 43 | public static class PrimaryLanguage { 44 | private String name; 45 | private String color; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/graphql/GitHubRepositoryStats.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto.graphql; 2 | 3 | import lombok.Data; 4 | 5 | import java.util.List; 6 | 7 | @Data 8 | public class GitHubRepositoryStats { 9 | 10 | private DataNode data; 11 | 12 | @Data 13 | public static class DataNode { 14 | private UserNode user; 15 | } 16 | 17 | @Data 18 | public static class UserNode { 19 | private RepositoryConnection repositories; 20 | } 21 | 22 | @Data 23 | public static class RepositoryConnection { 24 | private List edges; 25 | private PageInfo pageInfo; 26 | } 27 | 28 | @Data 29 | public static class RepositoryEdge { 30 | private RepositoryNode node; 31 | } 32 | 33 | @Data 34 | public static class RepositoryNode { 35 | private String name; 36 | private int stars; 37 | private int forkCount; 38 | private PrimaryLanguage primaryLanguage; 39 | private CommitsNode commits; 40 | private LanguageRootNode languages; 41 | } 42 | 43 | @Data 44 | public static class PrimaryLanguage { 45 | private String name; 46 | private String color; 47 | } 48 | 49 | @Data 50 | public static class LanguageRootNode { 51 | private List edges; 52 | } 53 | 54 | @Data 55 | public static class LanguageEdge { 56 | private LanguageNode node; 57 | private int size; 58 | } 59 | 60 | @Data 61 | public static class PageInfo { 62 | private boolean hasNextPage; 63 | private String endCursor; 64 | } 65 | 66 | @Data 67 | public static class CommitsNode { 68 | private TargetNode target; 69 | } 70 | 71 | @Data 72 | public static class TargetNode { 73 | private HistoryNode history; 74 | } 75 | 76 | @Data 77 | public static class HistoryNode { 78 | private int totalCount; 79 | } 80 | 81 | @Data 82 | public static class LanguageNode { 83 | private String name; 84 | private String color; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/dto/graphql/GraphQLRequest.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.dto.graphql; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | @AllArgsConstructor 10 | public class GraphQLRequest { 11 | private String query; 12 | } -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/model/GitHubStats.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.model; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.springframework.data.annotation.CreatedDate; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.mongodb.core.mapping.Document; 9 | 10 | import java.io.Serial; 11 | import java.io.Serializable; 12 | import java.util.ArrayList; 13 | import java.util.Date; 14 | import java.util.List; 15 | 16 | @Data 17 | @Document("stats") 18 | public class GitHubStats implements Serializable { 19 | @Serial 20 | private static final long serialVersionUID = 1L; 21 | @Id 22 | private String id; 23 | private String username; 24 | private String userId; 25 | private long totalCommits; 26 | private long totalIssuesClosed; 27 | private long totalPullRequestsClosed; 28 | private long totalStars; 29 | private long totalForks; 30 | private Repository topRepository; 31 | private List languagesStats = new ArrayList<>(); 32 | private ContributionCalendar contributionCalendar; 33 | @CreatedDate 34 | private Date createdDate; 35 | 36 | @Data 37 | public static class ContributionCalendar implements Serializable { 38 | @Serial 39 | private static final long serialVersionUID = 1L; 40 | private int totalContributions; 41 | private ArrayList weeks; 42 | } 43 | 44 | 45 | @Data 46 | public static class Week implements Serializable { 47 | @Serial 48 | private static final long serialVersionUID = 1L; 49 | private ArrayList contributionDays; 50 | } 51 | 52 | @Data 53 | public static class ContributionDay implements Serializable { 54 | @Serial 55 | private static final long serialVersionUID = 1L; 56 | private int weekday; 57 | private Date date; 58 | private int contributionCount; 59 | private String color; 60 | } 61 | 62 | 63 | @Data 64 | @AllArgsConstructor 65 | @NoArgsConstructor 66 | public static class LanguageStats implements Serializable { 67 | @Serial 68 | private static final long serialVersionUID = 1L; 69 | private String language; 70 | private String color; 71 | private long linesCount; 72 | } 73 | 74 | @Data 75 | @AllArgsConstructor 76 | @NoArgsConstructor 77 | public static class Repository implements Serializable { 78 | @Serial 79 | private static final long serialVersionUID = 1L; 80 | private String name; 81 | private String topLanguage; 82 | private String topLanguageColor; 83 | private long stars; 84 | private long forks; 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/model/GitHubUser.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.model; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.springframework.data.annotation.CreatedDate; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.mongodb.core.mapping.Document; 9 | 10 | import java.io.Serial; 11 | import java.io.Serializable; 12 | import java.util.ArrayList; 13 | import java.util.Date; 14 | import java.util.List; 15 | 16 | @Data 17 | @Document("user") 18 | public class GitHubUser implements Serializable { 19 | 20 | @Serial 21 | private static final long serialVersionUID = 1L; 22 | 23 | @Id 24 | private String id; 25 | private String username; 26 | private String name; 27 | private String bio; 28 | private String email; 29 | private String company; 30 | private String location; 31 | private String avatarUrl; 32 | private String blogUrl; 33 | private String twitterUsername; 34 | private int followers; 35 | private int following; 36 | private int publicRepos; 37 | private List pinnedRepositories = new ArrayList<>(); 38 | @CreatedDate 39 | private Date createdDate; 40 | 41 | @Data 42 | @AllArgsConstructor 43 | @NoArgsConstructor 44 | public static class PinnedRepositories implements Serializable { 45 | 46 | @Serial 47 | private static final long serialVersionUID = 1L; 48 | private String name; 49 | private String description; 50 | private String url; 51 | private int stars; 52 | private int forkCount; 53 | private String topLanguage; 54 | private String topLanguageColor; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/repository/GitHubStatsRepository.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.repository; 2 | 3 | import dev.amitwani.githubwrapped.model.GitHubStats; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.List; 8 | 9 | @Repository 10 | public interface GitHubStatsRepository extends MongoRepository { 11 | List findByUsername(String username); 12 | 13 | // Find top 6 users by commits 14 | List findTop6ByOrderByTotalCommitsDesc(); 15 | 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/repository/GitHubUserRepository.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.repository; 2 | 3 | import dev.amitwani.githubwrapped.dto.AllUserDTO; 4 | import dev.amitwani.githubwrapped.model.GitHubUser; 5 | import org.springframework.data.mongodb.repository.MongoRepository; 6 | import org.springframework.data.mongodb.repository.Query; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import java.util.List; 10 | 11 | @Repository 12 | public interface GitHubUserRepository extends MongoRepository { 13 | 14 | List findByUsername(String username); 15 | 16 | @Query(value = "{}", fields = "{ 'username' : 1, '_id' : 0 }") 17 | List findAllUsername(); 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/service/GitHubService.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.service; 2 | 3 | import dev.amitwani.githubwrapped.dto.graphql.GitHubContributionStats; 4 | import dev.amitwani.githubwrapped.dto.graphql.GitHubPinnedItems; 5 | import dev.amitwani.githubwrapped.dto.graphql.GitHubRepositoryStats; 6 | import dev.amitwani.githubwrapped.dto.graphql.GraphQLRequest; 7 | import jakarta.annotation.PostConstruct; 8 | import org.kohsuke.github.GHUser; 9 | import org.kohsuke.github.GitHub; 10 | import org.kohsuke.github.GitHubBuilder; 11 | import org.springframework.beans.factory.annotation.Value; 12 | import org.springframework.http.HttpEntity; 13 | import org.springframework.http.HttpHeaders; 14 | import org.springframework.http.HttpMethod; 15 | import org.springframework.http.ResponseEntity; 16 | import org.springframework.stereotype.Service; 17 | import org.springframework.web.client.RestTemplate; 18 | 19 | import java.io.IOException; 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | 23 | @Service 24 | public class GitHubService { 25 | 26 | private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(GitHubService.class); 27 | 28 | @Value("${github.graphql.url}") 29 | private String graphqlUrl; 30 | 31 | @Value("${github.token}") 32 | private String token; 33 | 34 | @Value("${github.username}") 35 | private String username; 36 | 37 | private GitHub unauthenticatedGitHub; 38 | private GitHub gitHub; 39 | private final RestTemplate restTemplate = new RestTemplate(); 40 | 41 | @PostConstruct 42 | public void init() throws IOException { 43 | unauthenticatedGitHub = new GitHubBuilder().build(); 44 | gitHub = new GitHubBuilder().withOAuthToken(token, username).build(); 45 | } 46 | 47 | public GHUser getGitHubUser(String username) throws IOException { 48 | try { 49 | return unauthenticatedGitHub.getUser(username); 50 | } catch (Exception e) { 51 | LOGGER.error("Failed to fetch user data using unauthenticated GitHub for user: {}", username, e); 52 | return gitHub.getUser(username); 53 | } 54 | } 55 | 56 | public GitHubPinnedItems getPinnedRepos(String username) { 57 | 58 | String graphqlQuery = """ 59 | query { 60 | user(login: "%s") { 61 | pinnedItems(first: 6, types: [REPOSITORY]) { 62 | edges { 63 | node { 64 | ... on Repository{ 65 | name, 66 | stars: stargazerCount 67 | description 68 | url 69 | forkCount 70 | primaryLanguage { 71 | name 72 | color 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | """.formatted(username); 81 | 82 | GraphQLRequest request = new GraphQLRequest(graphqlQuery); 83 | 84 | HttpHeaders headers = new HttpHeaders(); 85 | headers.set("Authorization", "Bearer " + token); 86 | headers.set("Content-Type", "application/json"); 87 | 88 | HttpEntity entity = new HttpEntity<>(request, headers); 89 | 90 | ResponseEntity response = restTemplate.exchange( 91 | graphqlUrl, 92 | HttpMethod.POST, 93 | entity, 94 | GitHubPinnedItems.class 95 | ); 96 | 97 | LOGGER.info("Response: {} {}", response.getStatusCode(), response.getBody()); 98 | LOGGER.info("Response Headers: {}", response.getHeaders()); 99 | 100 | 101 | return response.getBody(); 102 | } 103 | 104 | public List getRepositoryStats(String username) { 105 | 106 | String graphqlQuery = """ 107 | query { 108 | user(login: "%s") { 109 | repositories(first: 100, isFork: false) { 110 | edges { 111 | node { 112 | name 113 | stars: stargazerCount 114 | forkCount 115 | primaryLanguage { 116 | name 117 | color 118 | } 119 | commits: defaultBranchRef { 120 | target { 121 | ... on Commit { 122 | history(since: "2024-01-01T00:00:00Z", until: "2024-12-31T23:59:59Z") { 123 | totalCount 124 | } 125 | } 126 | } 127 | } 128 | languages(first: 100, orderBy: {field: SIZE, direction: DESC}) { 129 | edges { 130 | node { 131 | name 132 | color 133 | } 134 | size 135 | } 136 | } 137 | } 138 | } 139 | pageInfo { 140 | hasNextPage 141 | endCursor 142 | } 143 | } 144 | } 145 | } 146 | """.formatted(username); 147 | 148 | GraphQLRequest request = new GraphQLRequest(graphqlQuery); 149 | 150 | HttpHeaders headers = new HttpHeaders(); 151 | headers.set("Authorization", "Bearer " + token); 152 | headers.set("Content-Type", "application/json"); 153 | 154 | HttpEntity entity = new HttpEntity<>(request, headers); 155 | 156 | ResponseEntity response = restTemplate.exchange( 157 | graphqlUrl, 158 | HttpMethod.POST, 159 | entity, 160 | GitHubRepositoryStats.class 161 | ); 162 | 163 | LOGGER.info("Response: {} {}", response.getStatusCode(), response.getBody()); 164 | LOGGER.info("Response Headers: {}", response.getHeaders()); 165 | 166 | 167 | GitHubRepositoryStats contributionStats = response.getBody(); 168 | 169 | if (contributionStats == null) { 170 | return null; 171 | } 172 | 173 | List allRepositories = new ArrayList<>(contributionStats.getData().getUser().getRepositories().getEdges().stream().map(GitHubRepositoryStats.RepositoryEdge::getNode).toList()); 174 | 175 | // Handle pagination 176 | while (contributionStats.getData().getUser().getRepositories().getPageInfo().isHasNextPage()) { 177 | String cursor = contributionStats.getData().getUser().getRepositories().getPageInfo().getEndCursor(); 178 | graphqlQuery = """ 179 | query { 180 | user(login: "%s") { 181 | repositories(first: 100, after: "%s", isFork: false) { 182 | edges { 183 | node { 184 | name 185 | stars: stargazerCount 186 | forkCount 187 | primaryLanguage { 188 | name 189 | color 190 | } 191 | commits: defaultBranchRef { 192 | target { 193 | ... on Commit { 194 | history(since: "2024-01-01T00:00:00Z", until: "2024-12-31T23:59:59Z") { 195 | totalCount 196 | } 197 | } 198 | } 199 | } 200 | languages(first: 100, orderBy: {field: SIZE, direction: DESC}) { 201 | edges { 202 | node { 203 | name 204 | color 205 | } 206 | size 207 | } 208 | } 209 | } 210 | } 211 | pageInfo { 212 | hasNextPage 213 | endCursor 214 | } 215 | } 216 | } 217 | } 218 | """.formatted(username, cursor); 219 | 220 | request = new GraphQLRequest(graphqlQuery); 221 | entity = new HttpEntity<>(request, headers); 222 | 223 | response = restTemplate.exchange( 224 | graphqlUrl, 225 | HttpMethod.POST, 226 | entity, 227 | GitHubRepositoryStats.class 228 | ); 229 | 230 | LOGGER.info("Response: {} {}", response.getStatusCode(), response.getBody()); 231 | LOGGER.info("Response Headers: {}", response.getHeaders()); 232 | 233 | contributionStats = response.getBody(); 234 | 235 | if (contributionStats == null) { 236 | break; 237 | } 238 | 239 | allRepositories.addAll(contributionStats.getData().getUser().getRepositories().getEdges().stream().map(GitHubRepositoryStats.RepositoryEdge::getNode).toList()); 240 | } 241 | 242 | return allRepositories; 243 | 244 | } 245 | 246 | public GitHubContributionStats getContributionStats(String username) { 247 | 248 | String graphqlQuery = """ 249 | query { 250 | user(login: "%s") { 251 | contributionsCollection( 252 | from: "2024-01-01T00:00:00Z" 253 | to: "2024-12-31T23:59:59Z" 254 | ) { 255 | commits: totalCommitContributions 256 | issuesClosed: totalIssueContributions 257 | pullRequestsClosed: totalPullRequestContributions 258 | contributionCalendar { 259 | totalContributions 260 | weeks { 261 | contributionDays { 262 | weekday 263 | date 264 | contributionCount 265 | color 266 | } 267 | } 268 | } 269 | } 270 | } 271 | } 272 | """.formatted(username); 273 | 274 | GraphQLRequest request = new GraphQLRequest(graphqlQuery); 275 | 276 | HttpHeaders headers = new HttpHeaders(); 277 | headers.set("Authorization", "Bearer " + token); 278 | headers.set("Content-Type", "application/json"); 279 | 280 | HttpEntity entity = new HttpEntity<>(request, headers); 281 | 282 | ResponseEntity response = restTemplate.exchange( 283 | graphqlUrl, 284 | HttpMethod.POST, 285 | entity, 286 | GitHubContributionStats.class 287 | ); 288 | 289 | LOGGER.info("Response: {} {}", response.getStatusCode(), response.getBody()); 290 | LOGGER.info("Response Headers: {}", response.getHeaders()); 291 | 292 | return response.getBody(); 293 | 294 | } 295 | 296 | } 297 | -------------------------------------------------------------------------------- /backend/src/main/java/dev/amitwani/githubwrapped/service/StatsService.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped.service; 2 | 3 | import dev.amitwani.githubwrapped.dto.*; 4 | import dev.amitwani.githubwrapped.dto.graphql.*; 5 | import dev.amitwani.githubwrapped.model.*; 6 | import dev.amitwani.githubwrapped.repository.*; 7 | import org.kohsuke.github.GHUser; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.cache.annotation.Cacheable; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.stereotype.Service; 14 | 15 | import java.io.IOException; 16 | import java.util.*; 17 | import java.util.stream.Collectors; 18 | 19 | @Service 20 | public class StatsService { 21 | 22 | private static final Logger LOGGER = LoggerFactory.getLogger(StatsService.class); 23 | 24 | private final GitHubService gitHubService; 25 | private final GitHubUserRepository gitHubUserRepository; 26 | private final GitHubStatsRepository gitHubStatsRepository; 27 | 28 | @Autowired 29 | public StatsService(GitHubService gitHubService, GitHubUserRepository gitHubUserRepository, GitHubStatsRepository gitHubStatsRepository) { 30 | this.gitHubService = gitHubService; 31 | this.gitHubUserRepository = gitHubUserRepository; 32 | this.gitHubStatsRepository = gitHubStatsRepository; 33 | } 34 | 35 | public ResponseEntity generateGitHubStats(String username) { 36 | try { 37 | StatsDTO statsDTO = getStats(username.toLowerCase()); 38 | if (statsDTO != null) { 39 | LOGGER.info("User {} stats already exists", username); 40 | return ResponseEntity.ok(new ResponseDTO("User stats already exists", statsDTO)); 41 | } 42 | 43 | GHUser user = fetchGitHubUser(username); 44 | if (user == null) { 45 | return ResponseEntity.status(404).body(new ResponseDTO("No user data found", null)); 46 | } 47 | 48 | GitHubUser gitHubUser = processGitHubUser(user, username); 49 | GitHubStats gitHubStats = generateStats(username, gitHubUser); 50 | 51 | statsDTO = new StatsDTO(); 52 | statsDTO.setStats(gitHubStats); 53 | statsDTO.setUser(gitHubUser); 54 | statsDTO.setUsername(username.toLowerCase()); 55 | 56 | return ResponseEntity.status(201).body(new ResponseDTO("Stats generated successfully", statsDTO)); 57 | } catch (Exception e) { 58 | LOGGER.error("Error generating stats for user: {}", username, e); 59 | return ResponseEntity.internalServerError().body(new ResponseDTO("Error generating stats", null)); 60 | } 61 | } 62 | 63 | private GHUser fetchGitHubUser(String username) { 64 | try { 65 | GHUser user = gitHubService.getGitHubUser(username.toLowerCase()); 66 | LOGGER.info("Fetched user data for user: {}", user); 67 | return user; 68 | } catch (Exception e) { 69 | LOGGER.error("Failed to fetch user data for user: {}", username, e); 70 | return null; 71 | } 72 | } 73 | 74 | private GitHubUser processGitHubUser(GHUser user, String username) throws IOException { 75 | GitHubUser gitHubUser = new GitHubUser(); 76 | gitHubUser.setBio(user.getBio()); 77 | gitHubUser.setCompany(user.getCompany()); 78 | gitHubUser.setEmail(user.getEmail()); 79 | gitHubUser.setAvatarUrl(user.getAvatarUrl()); 80 | gitHubUser.setBlogUrl(user.getBlog()); 81 | gitHubUser.setFollowers(user.getFollowersCount()); 82 | gitHubUser.setFollowing(user.getFollowingCount()); 83 | gitHubUser.setPublicRepos(user.getPublicRepoCount()); 84 | gitHubUser.setName(user.getName()); 85 | gitHubUser.setTwitterUsername(user.getTwitterUsername()); 86 | gitHubUser.setUsername(username.toLowerCase()); 87 | 88 | GitHubPinnedItems pinnedRepos = gitHubService.getPinnedRepos(username.toLowerCase()); 89 | LOGGER.info("Fetched pinned repos for user: {}", pinnedRepos); 90 | 91 | List pinnedRepoNodes = pinnedRepos.getData().getUser().getPinnedItems().getEdges().stream() 92 | .map(GitHubPinnedItems.Edge::getNode) 93 | .toList(); 94 | 95 | for (GitHubPinnedItems.Node node : pinnedRepoNodes) { 96 | gitHubUser.getPinnedRepositories().add(new GitHubUser.PinnedRepositories( 97 | node.getName(), 98 | node.getDescription(), 99 | node.getUrl(), 100 | node.getStars(), 101 | node.getForkCount(), 102 | node.getPrimaryLanguage() != null ? node.getPrimaryLanguage().getName() : null, 103 | node.getPrimaryLanguage() != null ? node.getPrimaryLanguage().getColor() : null 104 | )); 105 | } 106 | 107 | gitHubUser.setCreatedDate(new Date()); 108 | return gitHubUserRepository.save(gitHubUser); 109 | } 110 | 111 | private GitHubStats generateStats(String username, GitHubUser gitHubUser) { 112 | GitHubStats gitHubStats = new GitHubStats(); 113 | gitHubStats.setUsername(username.toLowerCase()); 114 | 115 | List repositoryNodes = gitHubService.getRepositoryStats(username.toLowerCase()); 116 | 117 | Map languageStatsMap = repositoryNodes.stream() 118 | .flatMap(node -> node.getLanguages().getEdges().stream()) 119 | .collect(Collectors.toMap( 120 | edge -> edge.getNode().getName(), 121 | edge -> new GitHubStats.LanguageStats(edge.getNode().getName(), edge.getNode().getColor(), edge.getSize()), 122 | (existing, replacement) -> { 123 | existing.setLinesCount(existing.getLinesCount() + replacement.getLinesCount()); 124 | return existing; 125 | } 126 | )); 127 | 128 | List languageStats = languageStatsMap.values().stream() 129 | .sorted(Comparator.comparing(GitHubStats.LanguageStats::getLinesCount).reversed()) 130 | .collect(Collectors.toList()); 131 | 132 | gitHubStats.setLanguagesStats(languageStats); 133 | 134 | GitHubContributionStats contributionStats = gitHubService.getContributionStats(username.toLowerCase()); 135 | 136 | gitHubStats.setTotalCommits(contributionStats.getData().getUser().getContributionsCollection().getCommits()); 137 | gitHubStats.setTotalIssuesClosed(contributionStats.getData().getUser().getContributionsCollection().getIssuesClosed()); 138 | gitHubStats.setTotalPullRequestsClosed(contributionStats.getData().getUser().getContributionsCollection().getPullRequestsClosed()); 139 | gitHubStats.setContributionCalendar(contributionStats.getData().getUser().getContributionsCollection().getContributionCalendar()); 140 | 141 | GitHubStats.Repository topRepository = repositoryNodes.stream() 142 | .max(Comparator.comparing(GitHubRepositoryStats.RepositoryNode::getStars)) 143 | .map(node -> new GitHubStats.Repository( 144 | node.getName(), 145 | node.getPrimaryLanguage() != null ? node.getPrimaryLanguage().getName() : null, 146 | node.getPrimaryLanguage() != null ? node.getPrimaryLanguage().getColor() : null, 147 | node.getStars(), 148 | node.getForkCount() 149 | )) 150 | .orElse(null); 151 | 152 | gitHubStats.setTopRepository(topRepository); 153 | gitHubStats.setTotalStars(repositoryNodes.stream().mapToInt(GitHubRepositoryStats.RepositoryNode::getStars).sum()); 154 | gitHubStats.setTotalForks(repositoryNodes.stream().mapToInt(GitHubRepositoryStats.RepositoryNode::getForkCount).sum()); 155 | 156 | gitHubStats.setUserId(gitHubUser.getId()); 157 | return gitHubStatsRepository.save(gitHubStats); 158 | } 159 | 160 | public StatsDTO getStats(String username) { 161 | List gitHubUserList = gitHubUserRepository.findByUsername(username.toLowerCase()); 162 | if (gitHubUserList == null || gitHubUserList.isEmpty()) { 163 | LOGGER.info("User not found for username: {}", username); 164 | return null; 165 | } 166 | 167 | List gitHubStatsList = gitHubStatsRepository.findByUsername(username.toLowerCase()); 168 | if (gitHubStatsList == null || gitHubStatsList.isEmpty()) { 169 | LOGGER.info("User stats not found for username: {}", username); 170 | return null; 171 | } 172 | 173 | StatsDTO statsDTO = new StatsDTO(); 174 | statsDTO.setStats(gitHubStatsList.getFirst()); 175 | statsDTO.setUser(gitHubUserList.getFirst()); 176 | statsDTO.setUsername(username.toLowerCase()); 177 | 178 | return statsDTO; 179 | } 180 | 181 | public List getTopUsers() { 182 | List topUserList = new ArrayList<>(); 183 | List gitHubStatsList = gitHubStatsRepository.findTop6ByOrderByTotalCommitsDesc(); 184 | 185 | LOGGER.info("Fetched top users: {}", gitHubStatsList.size()); 186 | 187 | for (GitHubStats gitHubStats : gitHubStatsList) { 188 | TopUserDTO topUser = new TopUserDTO(); 189 | topUser.setUsername(gitHubStats.getUsername()); 190 | 191 | List gitHubUserList = gitHubUserRepository.findByUsername(gitHubStats.getUsername().toLowerCase()); 192 | if (gitHubUserList != null && !gitHubUserList.isEmpty()) { 193 | topUser.setName(gitHubUserList.getFirst().getName()); 194 | topUser.setAvatarUrl(gitHubUserList.getFirst().getAvatarUrl()); 195 | topUser.setUsername(gitHubUserList.getFirst().getUsername()); 196 | } 197 | 198 | topUser.setTotalContributions(gitHubStats.getContributionCalendar().getTotalContributions()); 199 | topUser.setTotalCommits(gitHubStats.getTotalCommits()); 200 | topUser.setTotalIssuesClosed(gitHubStats.getTotalIssuesClosed()); 201 | topUser.setTotalPullRequestsClosed(gitHubStats.getTotalPullRequestsClosed()); 202 | topUser.setTotalStars(gitHubStats.getTotalStars()); 203 | topUser.setTotalForks(gitHubStats.getTotalForks()); 204 | topUserList.add(topUser); 205 | } 206 | 207 | topUserList.sort(Comparator.comparing(TopUserDTO::getTotalContributions).reversed()); 208 | 209 | return topUserList; 210 | } 211 | 212 | public List getAllUsers() { 213 | return gitHubUserRepository.findAllUsername(); 214 | } 215 | } -------------------------------------------------------------------------------- /backend/src/main/resources/application-local.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 9009 3 | 4 | spring: 5 | application: 6 | name: GitHub-Wrapped 7 | data: 8 | mongodb: 9 | uri: mongodb://localhost:27017/githubwrapped 10 | 11 | auth: 12 | token: AUTH_TOKEN 13 | 14 | github: 15 | graphql: 16 | url: https://api.github.com/graphql 17 | username: GITHUB_USERNAME 18 | token: GITHUB_TOKEN -------------------------------------------------------------------------------- /backend/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 9009 3 | 4 | spring: 5 | application: 6 | name: GitHub-Wrapped 7 | 8 | -------------------------------------------------------------------------------- /backend/src/test/java/dev/amitwani/githubwrapped/GithubWrappedApplicationTests.java: -------------------------------------------------------------------------------- 1 | package dev.amitwani.githubwrapped; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class GithubWrappedApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.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 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /frontend/app/[username]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "@/components/navbar"; 2 | 3 | export default function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | <> 6 | 7 | {children} 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /frontend/app/[username]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
Loading...
5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/app/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | import ContributionBreakdown from "@/components/contribution-breakdown"; 4 | import ProfileHeader from "@/components/profile-header"; 5 | import SaveImageButton from "@/components/save-image"; 6 | import { 7 | ContributionCalendar, 8 | ContributionDay as ContributionDayType, 9 | StatsResponse, 10 | Week, 11 | } from "@/types/stats"; 12 | import Link from "next/link"; 13 | import { 14 | GitCommit, 15 | CircleSlash, 16 | GitPullRequest, 17 | Star, 18 | GitFork, 19 | } from "lucide-react"; 20 | import SocialShare from "@/components/social-share"; 21 | import { getStats } from "../actions/stats-action"; 22 | import { IdentifyComponent } from "@openpanel/nextjs"; 23 | import AIAnalysis from "@/components/ai-analysis"; 24 | 25 | export async function generateMetadata({ 26 | params, 27 | }: { 28 | params: { username: string }; 29 | }): Promise { 30 | const username = params.username; 31 | const stats = await getStats(username); 32 | 33 | if (!stats?.data) { 34 | return { 35 | metadataBase: new URL("https://githubwrapped.xyz"), 36 | title: "User Not Found", 37 | description: "This GitHub profile could not be found.", 38 | }; 39 | } 40 | 41 | const { user } = stats.data; 42 | 43 | return { 44 | metadataBase: new URL("https://githubwrapped.xyz"), 45 | title: `${user.name || username}'s GitHub Wrapped 2024`, 46 | description: `Check out ${ 47 | user.name || username 48 | }'s GitHub contributions and coding stats for 2024. ${user.bio || ""}`, 49 | openGraph: { 50 | title: `${user.name || username}'s GitHub Wrapped 2024`, 51 | description: `Check out ${ 52 | user.name || username 53 | }'s GitHub contributions and coding stats for 2024`, 54 | images: [ 55 | { 56 | url: `/api/${username}/og`, 57 | width: 1200, 58 | height: 630, 59 | alt: `${user.name || username}'s GitHub Wrapped 2024`, 60 | }, 61 | ], 62 | }, 63 | twitter: { 64 | card: "summary_large_image", 65 | title: `${user.name || username}'s GitHub Wrapped 2024`, 66 | description: `Check out ${ 67 | user.name || username 68 | }'s GitHub contributions and coding stats for 2024`, 69 | images: [`/api/${username}/og`], 70 | }, 71 | }; 72 | } 73 | 74 | function ContributionDay({ day }: { day: ContributionDayType }) { 75 | return ( 76 |
84 | ); 85 | } 86 | 87 | function ContributionGraph({ 88 | calendar, 89 | className, 90 | }: { 91 | calendar: ContributionCalendar; 92 | className?: string; 93 | }) { 94 | return ( 95 |
98 |

99 | Contribution Graph (2024) 100 |

101 |
102 |
103 | {calendar.weeks.map((week: Week, weekIndex: number) => ( 104 |
105 | {week.contributionDays.map( 106 | (day: ContributionDayType, dayIndex: number) => ( 107 | 108 | ) 109 | )} 110 |
111 | ))} 112 |
113 |
114 | More 115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | Less 123 |
124 |
125 |
126 | ); 127 | } 128 | 129 | export default async function GitHubWrapped({ 130 | params, 131 | }: { 132 | params: { username: string }; 133 | }) { 134 | const { username } = params; 135 | const stats: StatsResponse = await getStats(username); 136 | 137 | if (!stats?.data) { 138 | return ( 139 |
140 |

141 | No GitHub Wrapped found for 142 |

143 |

144 | {username} 145 |

146 | 151 | Generate your GitHub Wrapped 152 | 153 |
154 | ); 155 | } 156 | 157 | const { user, stats: githubStats } = stats.data; 158 | 159 | return ( 160 | <> 161 | 173 |
174 | {/* Profile Header */} 175 | 176 | 177 |
178 | 179 | 180 |
181 | 182 | {/* AI Analysis */} 183 | 184 | 185 | {/* Stats Grid */} 186 |
187 | {/* Contribution Stats */} 188 |
189 |

190 | Contributions (2024) 191 |

192 |
193 |
194 |
195 | 196 | 197 | Total Commits 198 | 199 |
200 | 201 | {githubStats.totalCommits} 202 | 203 |
204 |
205 |
206 | 207 | 208 | Issues Closed 209 | 210 |
211 | 212 | {githubStats.totalIssuesClosed} 213 | 214 |
215 |
216 |
217 | 218 | 219 | PRs Merged 220 | 221 |
222 | 223 | {githubStats.totalPullRequestsClosed} 224 | 225 |
226 |
227 |
228 | 229 | {/* Repository Stats */} 230 |
231 |

232 | Repository Impact 233 |

234 |
235 |
236 |
237 | 238 | 239 | Total Stars 240 | 241 |
242 | 243 | {githubStats.totalStars} 244 | 245 |
246 |
247 |
248 | 249 | 250 | Total Forks 251 | 252 |
253 | 254 | {githubStats.totalForks} 255 | 256 |
257 |
258 |
259 | 260 | {/* Top Languages */} 261 |
262 |

263 | Top Languages 264 |

265 |
266 | {githubStats.languagesStats.slice(0, 5).map((lang) => ( 267 |
268 |
272 | 273 | {lang.language} 274 | 275 | 276 | {( 277 | ((lang.linesCount || 0) / 278 | githubStats.languagesStats.reduce( 279 | (acc, curr) => acc + (curr.linesCount || 0), 280 | 0 281 | )) * 282 | 100 283 | ).toFixed(1)} 284 | % 285 | 286 |
287 | ))} 288 |
289 |
290 |
291 | 292 | {/* Top Repository */} 293 |
294 |

295 | Top Repository 296 |

297 |
298 |
299 |
300 |
301 | 308 | {githubStats.topRepository?.name} 309 | 310 |
311 |
312 |
313 |
314 | 315 | ⭐ {githubStats.topRepository?.stars} 316 | 317 |
318 |
319 | 320 | 🍴 {githubStats.topRepository?.forks} 321 | 322 |
323 |
324 |
325 |
326 |
327 | 328 | {/* Contribution Graph */} 329 | 333 | 334 | {/* Contribution Breakdown */} 335 | 336 | 337 | {/* Pinned Repositories */} 338 | {user.pinnedRepositories.length > 0 && ( 339 |
340 |

341 | Pinned Repositories 342 |

343 |
344 | {user.pinnedRepositories.map((repo) => ( 345 | 353 |

354 | {repo.name} 355 |

356 | {repo.description && ( 357 |

358 | {repo.description} 359 |

360 | )} 361 |
362 | {repo.topLanguage && ( 363 |
364 |
370 | 371 | {repo.topLanguage} 372 | 373 |
374 | )} 375 | {repo.stars && repo.stars > 0 && ( 376 |
377 | 378 | ⭐ {repo.stars} 379 | 380 |
381 | )} 382 | {repo.forkCount && repo.forkCount > 0 && ( 383 |
384 | 385 | 🍴 {repo.forkCount} 386 | 387 |
388 | )} 389 |
390 | 391 | ))} 392 |
393 |
394 | )} 395 |
396 | 397 | ); 398 | } 399 | -------------------------------------------------------------------------------- /frontend/app/about/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | metadataBase: new URL("https://githubwrapped.xyz"), 5 | title: "About GitHub Wrapped", 6 | description: 7 | "Your Year in Code 2024 - View your GitHub contributions, stats, and coding journey for 2024.", 8 | }; 9 | 10 | export default function AboutLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | return
{children}
; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Github } from "lucide-react"; 4 | 5 | export default function AboutPage() { 6 | return ( 7 |
8 |
9 | {/* Header */} 10 |
11 |

12 | About GitHub Wrapped 13 |

14 |

15 | Your personalized year in review for GitHub contributions and coding 16 | activity. 17 |

18 |
19 | 20 | {/* Main Content */} 21 |
22 |
23 |

24 | What is GitHub Wrapped? 25 |

26 |

27 | GitHub Wrapped provides developers with beautiful, shareable 28 | insights into their coding journey throughout 2024. See your 29 | contributions, most active repositories, favorite languages, and 30 | more - all in one place. 31 |

32 |
33 | 34 |
35 |

Features

36 |
    37 |
  • Visualize your contribution patterns
  • 38 |
  • Track commits, pull requests, and issues
  • 39 |
  • See your most-used programming languages
  • 40 |
  • Share your stats on social media
  • 41 |
  • Compare with top GitHub contributors
  • 42 |
43 |
44 | 45 |
46 |

Open Source

47 |

48 | GitHub Wrapped is open source and available on GitHub. 49 | Contributions are welcome! 50 |

51 |
52 | 56 | 60 | 61 | 62 | 65 | 66 |
67 |
68 |
69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /frontend/app/actions/all-user-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | export const getAllUsers = async () => { 4 | const response = await fetch( 5 | `${process.env.BACKEND_URL}/api/stats/all`, 6 | { 7 | method: "GET", 8 | headers: { 9 | Authorization: `${process.env.BACKEND_AUTH_TOKEN}`, 10 | }, 11 | next: { 12 | revalidate: 60 * 60, 13 | }, 14 | } 15 | ); 16 | if (response.ok) { 17 | return response.json(); 18 | } else { 19 | return { error: "Error fetching top users" }; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/app/actions/stats-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | export const generateWrapped = async (username: string) => { 4 | const response = await fetch( 5 | `${process.env.BACKEND_URL}/api/stats/${username}`, 6 | { 7 | method: "POST", 8 | headers: { 9 | Authorization: `${process.env.BACKEND_AUTH_TOKEN}`, 10 | }, 11 | } 12 | ); 13 | if (response.ok) { 14 | return response.json(); 15 | } else if (response.status === 404) { 16 | return { error: "Invalid GitHub username" }; 17 | } else { 18 | return { error: "Error generating wrapped" }; 19 | } 20 | }; 21 | 22 | export const getStats = async (username: string) => { 23 | try { 24 | const response = await fetch( 25 | `${process.env.BACKEND_URL}/api/stats/${username}`, 26 | { 27 | next: { 28 | revalidate: 60 * 60, 29 | }, 30 | headers: { 31 | Authorization: `${process.env.BACKEND_AUTH_TOKEN}`, 32 | "Content-Type": "application/json", 33 | }, 34 | } 35 | ); 36 | if (response.ok) { 37 | const data = await response.json(); 38 | return data; 39 | } 40 | } catch (error) { 41 | console.error("Error fetching stats:", error); 42 | } 43 | return null; 44 | }; -------------------------------------------------------------------------------- /frontend/app/actions/top-user-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | export const getTopUsers = async () => { 4 | const response = await fetch( 5 | `${process.env.BACKEND_URL}/api/stats/top`, 6 | { 7 | method: "GET", 8 | headers: { 9 | Authorization: `${process.env.BACKEND_AUTH_TOKEN}`, 10 | }, 11 | next: { 12 | revalidate: 10 * 60, 13 | }, 14 | } 15 | ); 16 | if (response.ok) { 17 | return response.json(); 18 | } else { 19 | return { error: "Error fetching top users" }; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/app/api/[username]/og/route.js: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | import { getStats } from "@/app/actions/stats-action"; 3 | 4 | export const runtime = "edge"; 5 | async function loadGoogleFont(font, text) { 6 | const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent( 7 | text 8 | )}`; 9 | const css = await (await fetch(url)).text(); 10 | const resource = css.match( 11 | /src: url\((.+)\) format\('(opentype|truetype)'\)/ 12 | ); 13 | 14 | if (resource) { 15 | const response = await fetch(resource[1]); 16 | if (response.status == 200) { 17 | return await response.arrayBuffer(); 18 | } 19 | } 20 | 21 | throw new Error("failed to load font data"); 22 | } 23 | export async function GET(request) { 24 | const { searchParams } = new URL(request.url); 25 | const username = searchParams.get("username"); 26 | 27 | if (!username) { 28 | return new Response("Username is required", { status: 400 }); 29 | } 30 | 31 | const stats = await getStats(username); 32 | 33 | if (!stats) { 34 | return new Response("User not found", { status: 404 }); 35 | } 36 | 37 | return new ImageResponse( 38 | ( 39 |
54 |
64 | GitHub Wrapped 2024 65 |
66 | 67 |
77 | 86 |
92 |
101 | {stats.data.user.name} 102 |
103 |
110 | @{username} 111 |
112 |
113 |
114 | 115 |
123 | {[ 124 | { 125 | value: stats.data.stats.contributionCalendar.totalContributions, 126 | label: "Total Contributions", 127 | color: "#00ff87", 128 | }, 129 | { 130 | value: stats.data.stats.totalCommits, 131 | label: "Commits", 132 | color: "#00cc6a", 133 | }, 134 | { 135 | value: stats.data.stats.totalIssuesClosed, 136 | label: "Issues Closed", 137 | color: "#00b359", 138 | }, 139 | { 140 | value: stats.data.stats.totalPullRequestsClosed, 141 | label: "PRs Merged", 142 | color: "#009933", 143 | }, 144 | ].map((stat, i) => ( 145 |
158 |
166 | {stat.value} 167 |
168 |
175 | {stat.label} 176 |
177 |
178 | ))} 179 |
180 |
181 | ), 182 | { 183 | width: 1200, 184 | height: 630, 185 | fonts: [ 186 | { 187 | name: "Geist Mono", 188 | data: await loadGoogleFont( 189 | "Geist Mono", 190 | "GitHub Wrapped 2024 " + username 191 | ), 192 | style: "normal", 193 | }, 194 | ], 195 | } 196 | ); 197 | } 198 | -------------------------------------------------------------------------------- /frontend/app/api/ai/route.ts: -------------------------------------------------------------------------------- 1 | import { Data } from '@/types/stats'; 2 | import { createOpenRouter } from '@openrouter/ai-sdk-provider'; 3 | import { streamText } from 'ai'; 4 | 5 | const openrouter = createOpenRouter({ 6 | apiKey: process.env.OPENROUTER_API_KEY!, 7 | }); 8 | export async function POST(req: Request) { 9 | const data: Data = await req.json(); 10 | const { user, stats } = data; 11 | 12 | const calendar = stats.contributionCalendar; 13 | 14 | // Process calendar data to get monthly contributions 15 | const monthlyContributions = Array(12) 16 | .fill(0) 17 | .map((_, index) => { 18 | const month = new Date(2024, index).toLocaleString("default", { 19 | month: "short", 20 | }); 21 | let total = 0; 22 | calendar.weeks.forEach((week) => { 23 | week.contributionDays.forEach((day) => { 24 | if (day.date && new Date(day.date).getMonth() === index) { 25 | total += day.contributionCount || 0; 26 | } 27 | }); 28 | }); 29 | return { name: month, total }; 30 | }); 31 | 32 | // Process calendar data to get daily contributions 33 | const dailyContributions = Array(7) 34 | .fill(0) 35 | .map((_, index) => { 36 | const day = new Date(2024, 0, index + 1).toLocaleString("default", { 37 | weekday: "short", 38 | }); 39 | let total = 0; 40 | calendar.weeks.forEach((week) => { 41 | week.contributionDays.forEach((d) => { 42 | if (d.weekday === index) { 43 | total += d.contributionCount || 0; 44 | } 45 | }); 46 | }); 47 | return { name: day, total }; 48 | }); 49 | 50 | // Calculate longest streak 51 | let currentStreak = 0; 52 | let longestStreak = 0; 53 | calendar.weeks 54 | .flatMap((week) => week.contributionDays) 55 | .forEach((day) => { 56 | if (day.contributionCount && day.contributionCount > 0) { 57 | currentStreak++; 58 | } else { 59 | longestStreak = Math.max(longestStreak, currentStreak); 60 | currentStreak = 0; 61 | } 62 | }); 63 | longestStreak = Math.max(longestStreak, currentStreak); 64 | 65 | // Calculate longest gap 66 | let longestGap = 0; 67 | let currentGap = 0; 68 | calendar.weeks 69 | .flatMap((week) => week.contributionDays) 70 | .forEach((day) => { 71 | if (!day.contributionCount || day.contributionCount === 0) { 72 | currentGap++; 73 | } else { 74 | longestGap = Math.max(longestGap, currentGap); 75 | currentGap = 0; 76 | } 77 | }); 78 | longestGap = Math.max(longestGap, currentGap); 79 | 80 | // Weekend activity 81 | const weekendActivity = calendar.weeks 82 | .flatMap((week) => week.contributionDays) 83 | .filter( 84 | (day) => 85 | (day.weekday === 0 || day.weekday === 6) && 86 | day.contributionCount && 87 | day.contributionCount > 0 88 | ).length; 89 | 90 | // Active Days 91 | const activeDays = calendar.weeks 92 | .flatMap((week) => week.contributionDays) 93 | .filter((day) => day.contributionCount && day.contributionCount > 0).length; 94 | 95 | const request = { 96 | username: data.username, 97 | name: user.name, 98 | bio: user.bio, 99 | blogUrl: user.blogUrl, 100 | twitterUsername: user.twitterUsername, 101 | followers: user.followers, 102 | following: user.following, 103 | publicRepos: user.publicRepos, 104 | pinnedRepositories: user.pinnedRepositories, 105 | totalCommits: stats.totalCommits, 106 | totalIssuesClosed: stats.totalIssuesClosed, 107 | totalPullRequestsClosed: stats.totalPullRequestsClosed, 108 | totalStars: stats.totalStars, 109 | totalForks: stats.totalForks, 110 | topRepository: stats.topRepository, 111 | languagesStats: stats.languagesStats, 112 | monthlyContributions, 113 | dailyContributions, 114 | longestStreak, 115 | longestGap, 116 | weekendActivity, 117 | activeDays, 118 | }; 119 | 120 | console.log("Request to AI:", request); 121 | 122 | const result = await streamText({ 123 | model: openrouter.chat('mistralai/mistral-7b-instruct'), 124 | prompt: `You are a creative assistant tasked with generating a fun and engaging GitHubWrapped summary for a user based on their GitHub activity. The user's data includes stats like total commits, contributions calendar, repositories, and more. Based on this, generate the following brief and concise sections (each no longer than 2-3 sentences): 125 | 126 | 0. Your Year in Code: Write a short summary of the user's year in code. 127 | 1. Code Superpower of the Year: Assign a fun superpower title based on the user's standout activity and briefly explain why. 128 | 2. Commit Horoscope: Create a GitHub-themed horoscope reflecting the user's contribution patterns, with one key piece of advice for next year. 129 | 3. The Week of Wonders: Identify the user's most active week and celebrate it in 1-2 sentences. 130 | 4. The Lazy Coder’s Award: Humorously highlight a period of inactivity in 1-2 sentences. 131 | 5. Open Source Spirit Animal: Assign a spirit animal based on the user's coding style and explain the connection in one sentence. 132 | 6. Roast the user: Roast the user so hard that they will never forget this year. 133 | 134 | Use a lighthearted, creative, and engaging tone. Heavy use of emojis. Keep the output concise and avoid unnecessary details. Use English language only. 135 | 136 | Give output in below markdown format: 137 | 138 | **Your Year in Code:** 139 | **Code Superpower of the Year:** 140 | 141 | **Commit Horoscope:** 142 | 143 | **The Week of Wonders:** 144 | 145 | **The Lazy Coder’s Award:** 146 | 147 | **Open Source Spirit Animal:** 148 | 149 | **Roast the user:** 150 | 151 | GitHub Stats for ${request.username} in last year 2024: ${JSON.stringify(request)}`, 152 | headers: { 153 | 'HTTP-Referer': 'https://githubwrapped.xyz', 154 | 'X-Title': 'GitHub Wrapped 2024 - Your Year in Code', 155 | }, 156 | temperature: 1, 157 | maxTokens: 1024 158 | }); 159 | 160 | // console.log("Results from AI:", text); 161 | 162 | return result.toTextStreamResponse(); 163 | } -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtwn105/GitHubWrapped/a229cc2a4810c222ed2aa94964faea17a90d9925/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtwn105/GitHubWrapped/a229cc2a4810c222ed2aa94964faea17a90d9925/frontend/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: GeistMono, Arial, Helvetica, sans-serif; 7 | background-color: #000; 8 | } 9 | 10 | @layer utilities { 11 | .text-balance { 12 | text-wrap: balance; 13 | } 14 | } 15 | 16 | @layer base { 17 | :root { 18 | --background: 0 0% 100%; 19 | --foreground: 0 0% 3.9%; 20 | --card: 0 0% 100%; 21 | --card-foreground: 0 0% 3.9%; 22 | --popover: 0 0% 100%; 23 | --popover-foreground: 0 0% 3.9%; 24 | --primary: 0 0% 9%; 25 | --primary-foreground: 0 0% 98%; 26 | --secondary: 0 0% 96.1%; 27 | --secondary-foreground: 0 0% 9%; 28 | --muted: 0 0% 96.1%; 29 | --muted-foreground: 0 0% 45.1%; 30 | --accent: 0 0% 96.1%; 31 | --accent-foreground: 0 0% 9%; 32 | --destructive: 0 84.2% 60.2%; 33 | --destructive-foreground: 0 0% 98%; 34 | --border: 0 0% 89.8%; 35 | --input: 0 0% 89.8%; 36 | --ring: 0 0% 3.9%; 37 | --chart-1: 12 76% 61%; 38 | --chart-2: 173 58% 39%; 39 | --chart-3: 197 37% 24%; 40 | --chart-4: 43 74% 66%; 41 | --chart-5: 27 87% 67%; 42 | --radius: 0.5rem; 43 | } 44 | .dark { 45 | --background: 0 0% 3.9%; 46 | --foreground: 0 0% 98%; 47 | --card: 0 0% 3.9%; 48 | --card-foreground: 0 0% 98%; 49 | --popover: 0 0% 3.9%; 50 | --popover-foreground: 0 0% 98%; 51 | --primary: 0 0% 98%; 52 | --primary-foreground: 0 0% 9%; 53 | --secondary: 0 0% 14.9%; 54 | --secondary-foreground: 0 0% 98%; 55 | --muted: 0 0% 14.9%; 56 | --muted-foreground: 0 0% 63.9%; 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | --destructive: 0 62.8% 30.6%; 60 | --destructive-foreground: 0 0% 98%; 61 | --border: 0 0% 14.9%; 62 | --input: 0 0% 14.9%; 63 | --ring: 0 0% 83.1%; 64 | --chart-1: 220 70% 50%; 65 | --chart-2: 160 60% 45%; 66 | --chart-3: 30 80% 55%; 67 | --chart-4: 280 65% 60%; 68 | --chart-5: 340 75% 55%; 69 | } 70 | } 71 | 72 | @layer base { 73 | * { 74 | @apply border-border; 75 | } 76 | body { 77 | @apply bg-background text-foreground; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import Footer from "@/components/footer"; 5 | import { ToasterProvider } from "@/components/ui/toaster"; 6 | import { OpenPanelComponent } from "@openpanel/nextjs"; 7 | import { CSPostHogProvider } from "./posthog"; 8 | import DonateButton from "@/components/donate-button"; 9 | 10 | const geistMono = localFont({ 11 | src: "./fonts/GeistMonoVF.woff", 12 | variable: "--font-geist-mono", 13 | weight: "100 900", 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | metadataBase: new URL("https://githubwrapped.xyz"), 18 | title: { 19 | default: "GitHub Wrapped 2024", 20 | template: "%s | GitHub Wrapped", 21 | }, 22 | description: 23 | "Your Year in Code 2024 - View your GitHub contributions, stats, and coding journey for 2024.", 24 | keywords: [ 25 | "github", 26 | "developer", 27 | "coding", 28 | "contributions", 29 | "stats", 30 | "wrapped", 31 | "2024", 32 | ], 33 | authors: [{ name: "Amit Wani" }], 34 | creator: "Amit Wani", 35 | openGraph: { 36 | type: "website", 37 | locale: "en_US", 38 | siteName: "GitHub Wrapped 2024", 39 | images: [ 40 | { 41 | url: "https://githubwrapped.xyz/github-wrapped-og.png", 42 | width: 1200, 43 | height: 630, 44 | }, 45 | ], 46 | }, 47 | twitter: { 48 | card: "summary_large_image", 49 | creator: "@mtwn105", 50 | site: "@mtwn105", 51 | images: [ 52 | { 53 | url: "https://githubwrapped.xyz/github-wrapped-og.png", 54 | width: 1200, 55 | height: 630, 56 | }, 57 | ], 58 | }, 59 | robots: { 60 | index: true, 61 | follow: true, 62 | googleBot: { 63 | index: true, 64 | follow: true, 65 | "max-video-preview": -1, 66 | "max-image-preview": "large", 67 | "max-snippet": -1, 68 | }, 69 | }, 70 | }; 71 | 72 | export default function RootLayout({ 73 | children, 74 | }: Readonly<{ 75 | children: React.ReactNode; 76 | }>) { 77 | return ( 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 90 | {children} 91 | 92 |