├── public ├── robots.txt ├── logo.webp └── favicon.ico ├── .github └── FUNDING.yml ├── tsconfig.json ├── .gitignore ├── package.json ├── nuxt.config.ts ├── types └── types.ts ├── LICENSE ├── README.md ├── server └── api │ └── changelog.get.ts ├── lib └── changelogGenerator.ts └── pages └── index.vue /public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 0pandadev 2 | -------------------------------------------------------------------------------- /public/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/changelog/main/public/logo.webp -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0PandaDEV/changelog/main/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | bun.lock 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@octokit/rest": "21.1.1", 14 | "github-markdown-css": "5.8.1", 15 | "markdown-it": "14.1.0", 16 | "node-emoji": "2.2.0", 17 | "nuxt": "3.17.3", 18 | "vue": "3.5.14", 19 | "vue-router": "4.5.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | compatibilityDate: "2024-11-01", 4 | devtools: { enabled: false }, 5 | app: { 6 | head: { 7 | script: 8 | process.env.NODE_ENV === "production" 9 | ? [ 10 | { 11 | src: "https://rybbit.pandadev.net/api/script.js", 12 | defer: true, 13 | "data-site-id": "3", 14 | }, 15 | ] 16 | : [], 17 | link: [ 18 | { 19 | rel: "preload", 20 | href: "/github-markdown-css/github-markdown-dark.css", 21 | as: "style", 22 | }, 23 | ], 24 | }, 25 | }, 26 | nitro: { 27 | preset: "cloudflare-pages", 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | import type { RestEndpointMethodTypes } from '@octokit/plugin-rest-endpoint-methods'; 2 | 3 | export type CommitData = RestEndpointMethodTypes['repos']['compareCommits']['response']['data']['commits'][number]; 4 | 5 | export interface ParsedCommit { 6 | type?: string; 7 | scope?: string; 8 | subject?: string; 9 | body?: string; 10 | sha: string; 11 | url: string; 12 | author?: string; 13 | authorUrl?: string; 14 | } 15 | 16 | export interface CommitType { 17 | types: string[]; 18 | header: string; 19 | icon: string; 20 | } 21 | 22 | export interface ChangelogOptions { 23 | githubUrl: string; 24 | fromTag?: string; 25 | toTag?: string; 26 | excludeTypes?: string[]; 27 | excludeScopes?: string[]; 28 | restrictToTypes?: string[]; 29 | includeRefIssues?: boolean; 30 | useGitmojis?: boolean; 31 | includeInvalidCommits?: boolean; 32 | reverseOrder?: boolean; 33 | } 34 | 35 | export interface TagPair { 36 | latest: string; 37 | previous: string; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 PandaDEV 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 Changelog Generator 2 | 3 | Try it at: 4 | 5 | A modern, fully client-side web application that generates beautiful changelogs from GitHub repositories. 6 | 7 | ![image](https://github.com/user-attachments/assets/17f8acb9-adc5-4db2-843e-e3e83579d79e) 8 | 9 | ## ✨ Features 10 | 11 | - 📝 Generate changelogs from any public GitHub repository 12 | - 🔒 Support for private repositories with GitHub token 13 | - 🏷️ Filter by tags and commit types 14 | - 😀 Gitmoji support 15 | - ⭐ Markdown rendering with GitHub styling 16 | - 📱 Mobile responsive design 17 | - 📋 Copy to clipboard functionality 18 | - 🌐 Fully client-side - no server needed 19 | - 📄 Generate changelogs from GitHub repositories based on conventional commits 20 | - 🔍 Support for filtering by tags 21 | - 🎨 Customizable output (exclude types, use gitmojis, etc.) 22 | - 📋 Copy to clipboard functionality 23 | - 📄 JSON output for API usage 24 | - 🔗 URL parameter sharing 25 | 26 | ## 🛠️ Setup 27 | 28 | ```bash 29 | # 📦 Install dependencies 30 | bun install 31 | 32 | # 🔥 Start development server 33 | bun dev 34 | 35 | # 🏗️ Build for production 36 | bun run build && node .output/server/index.mjs 37 | ``` 38 | 39 | ## API Usage 40 | 41 | There are two ways to use the changelog as an API: 42 | 43 | ### 1. JSON View in UI 44 | 45 | You can use the "View as JSON" button after generating a changelog to access the JSON API endpoint. 46 | 47 | Example: `https://changelog.pandadev.net/?url=https://github.com/owner/repo` 48 | 49 | ### 2. REST API Endpoint 50 | 51 | A dedicated REST API endpoint is available at `/api/changelog` that returns pure JSON with proper application/json content type. 52 | 53 | #### API Request 54 | 55 | ``` 56 | GET /api/changelog?url=https://github.com/owner/repo 57 | ``` 58 | 59 | #### Query Parameters 60 | 61 | - `url`: GitHub repository URL (required) 62 | - `fromTag`: Starting tag (optional) 63 | - `toTag`: Ending tag (optional) 64 | - `excludeTypes`: JSON array of commit types to exclude 65 | - `useGitmojis`: "true" or "false" (defaults to true) 66 | - `reverseOrder`: "true" or "false" (defaults to false) 67 | 68 | #### Authentication 69 | 70 | For private repositories, provide a GitHub token in the Authorization header: 71 | 72 | ``` 73 | Authorization: Bearer your-github-token 74 | ``` 75 | 76 | #### Response Format 77 | 78 | ```json 79 | { 80 | "version": "tag-name", 81 | "date": "YYYY-MM-DD", 82 | "sections": { 83 | "New Features": [ 84 | { 85 | "hash": "7-character-hash", 86 | "url": "commit-url", 87 | "scope": "scope-name", 88 | "subject": "commit message", 89 | "author": "username", 90 | "authorUrl": "profile-url" 91 | } 92 | ], 93 | "Bug Fixes": [ 94 | // ... 95 | ] 96 | }, 97 | "fromTag": "previous-tag", 98 | "toTag": "current-tag", 99 | "markdown": "## [tag-name] - YYYY-MM-DD\n\n### New Features\n- [`hash`](url) - subject\n..." 100 | } 101 | ``` 102 | 103 | ## License 104 | 105 | MIT 106 | -------------------------------------------------------------------------------- /server/api/changelog.get.ts: -------------------------------------------------------------------------------- 1 | import { ChangelogGenerator } from "~/lib/changelogGenerator"; 2 | import type { ChangelogOptions } from "~/types/types"; 3 | 4 | interface ChangelogJson { 5 | version: string; 6 | date: string; 7 | sections: Record; 8 | fromTag?: string; 9 | toTag?: string; 10 | markdown?: string; 11 | } 12 | 13 | export default defineEventHandler(async (event) => { 14 | const query = getQuery(event); 15 | 16 | if (!query.url) { 17 | setResponseStatus(event, 400); 18 | return { error: "GitHub URL is required" }; 19 | } 20 | 21 | try { 22 | const options: Partial = { 23 | githubUrl: query.url as string, 24 | excludeTypes: query.excludeTypes 25 | ? JSON.parse(query.excludeTypes as string) 26 | : ["build", "docs", "other", "style"], 27 | includeRefIssues: query.includeRefIssues === "true", 28 | useGitmojis: query.useGitmojis !== "false", 29 | includeInvalidCommits: query.includeInvalidCommits !== "false", 30 | reverseOrder: query.reverseOrder === "true", 31 | }; 32 | 33 | if (query.fromTag) { 34 | options.fromTag = query.fromTag as string; 35 | } 36 | 37 | if (query.toTag) { 38 | options.toTag = query.toTag as string; 39 | } 40 | 41 | const authHeader = getHeader(event, "authorization"); 42 | const token = authHeader ? authHeader.replace("Bearer ", "") : ""; 43 | 44 | const generator = new ChangelogGenerator(token); 45 | const result = await generator.generate(options as ChangelogOptions); 46 | 47 | const jsonData: ChangelogJson = parseChangelogToJson(result.changelog); 48 | jsonData.fromTag = result.fromTag; 49 | jsonData.toTag = result.toTag; 50 | jsonData.markdown = result.changelog; 51 | 52 | setHeader(event, "Content-Type", "application/json"); 53 | return jsonData; 54 | } catch (error: any) { 55 | setResponseStatus(event, 500); 56 | return { 57 | error: error.message || "An error occurred generating the changelog", 58 | }; 59 | } 60 | }); 61 | 62 | function parseChangelogToJson(markdownChangelog: string): ChangelogJson { 63 | const lines = markdownChangelog.split("\n"); 64 | let version = ""; 65 | let date = ""; 66 | const sections: Record = {}; 67 | let currentSection = ""; 68 | 69 | for (const line of lines) { 70 | if (line.startsWith("## ")) { 71 | const versionMatch = line.match(/## \[(.*?)\] - (.*)/); 72 | if (versionMatch) { 73 | version = versionMatch[1]; 74 | date = versionMatch[2]; 75 | } 76 | } else if (line.startsWith("### ")) { 77 | currentSection = line.replace(/^### (:[\w+-]+: )?/, "").trim(); 78 | sections[currentSection] = []; 79 | } else if (line.startsWith("- ") && currentSection) { 80 | const commitMatch = line.match( 81 | /- \[`(.*?)`\]\((.*?)\) - (?:(?:\*\*(.*?)\*\*: ))?(.*)(?:(?: by \[@(.*?)\]\((.*?)\)))?/ 82 | ); 83 | if (commitMatch) { 84 | const [, hash, url, scope, subject, author, authorUrl] = commitMatch; 85 | sections[currentSection].push({ 86 | hash, 87 | url, 88 | scope: scope || null, 89 | subject, 90 | author: author || null, 91 | authorUrl: authorUrl || null, 92 | }); 93 | } 94 | } 95 | } 96 | 97 | return { 98 | version, 99 | date, 100 | sections, 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /lib/changelogGenerator.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from "@octokit/rest"; 2 | import type { 3 | ParsedCommit, 4 | CommitType, 5 | ChangelogOptions, 6 | TagPair, 7 | } from "../types/types"; 8 | 9 | interface CacheEntry { 10 | data: T; 11 | timestamp: number; 12 | } 13 | 14 | export class ChangelogGenerator { 15 | private octokit: Octokit; 16 | private cache: Map>; 17 | private cacheTTL: number = 3600000; 18 | 19 | constructor(token?: string) { 20 | this.octokit = new Octokit({ auth: token }); 21 | this.cache = new Map(); 22 | } 23 | 24 | private getCacheKey(method: string, params: Record): string { 25 | return `${method}:${JSON.stringify(params)}`; 26 | } 27 | 28 | private getFromCache(cacheKey: string): T | null { 29 | const cached = this.cache.get(cacheKey); 30 | if (cached && Date.now() - cached.timestamp < this.cacheTTL) { 31 | return cached.data; 32 | } 33 | return null; 34 | } 35 | 36 | private saveToCache(cacheKey: string, data: T): void { 37 | this.cache.set(cacheKey, { 38 | data, 39 | timestamp: Date.now(), 40 | }); 41 | } 42 | 43 | private async getTags(owner: string, repo: string): Promise { 44 | const cacheKey = this.getCacheKey("getTags", { owner, repo }); 45 | const cached = this.getFromCache(cacheKey); 46 | if (cached) return cached; 47 | 48 | const { data: tags } = await this.octokit.repos.listTags({ 49 | owner, 50 | repo, 51 | per_page: 100, 52 | }); 53 | 54 | const { data: repoData } = await this.octokit.repos.get({ owner, repo }); 55 | const defaultBranch = repoData.default_branch; 56 | 57 | if (tags.length === 0) { 58 | const result = { latest: defaultBranch, previous: "" }; 59 | this.saveToCache(cacheKey, result); 60 | return result; 61 | } 62 | 63 | const tagDetails = await Promise.all( 64 | tags.map(async (tag) => { 65 | const commitCacheKey = this.getCacheKey("getCommit", { 66 | owner, 67 | repo, 68 | commit_sha: tag.commit.sha, 69 | }); 70 | const cachedCommit = this.getFromCache(commitCacheKey); 71 | 72 | let commit; 73 | if (cachedCommit) { 74 | commit = cachedCommit; 75 | } else { 76 | const { data } = await this.octokit.git.getCommit({ 77 | owner, 78 | repo, 79 | commit_sha: tag.commit.sha, 80 | }); 81 | this.saveToCache(commitCacheKey, data); 82 | commit = data; 83 | } 84 | 85 | return { 86 | name: tag.name, 87 | date: new Date(commit.committer.date), 88 | }; 89 | }) 90 | ); 91 | 92 | tagDetails.sort((a, b) => b.date.getTime() - a.date.getTime()); 93 | 94 | const result = { 95 | latest: defaultBranch, 96 | previous: tagDetails[0].name, 97 | }; 98 | 99 | this.saveToCache(cacheKey, result); 100 | return result; 101 | } 102 | 103 | private async getCommits( 104 | owner: string, 105 | repo: string, 106 | head: string, 107 | base: string 108 | ) { 109 | const cacheKey = this.getCacheKey("getCommits", { 110 | owner, 111 | repo, 112 | head, 113 | base, 114 | }); 115 | const cached = this.getFromCache(cacheKey); 116 | if (cached) return cached; 117 | 118 | const commits = []; 119 | let page = 1; 120 | 121 | if (!base) { 122 | while (true) { 123 | const { data } = await this.octokit.repos.listCommits({ 124 | owner, 125 | repo, 126 | sha: head, 127 | per_page: 100, 128 | page, 129 | }); 130 | 131 | if (data.length === 0) break; 132 | commits.push(...data); 133 | if (data.length < 100) break; 134 | page++; 135 | } 136 | this.saveToCache(cacheKey, commits); 137 | return commits; 138 | } 139 | 140 | while (true) { 141 | const { data } = await this.octokit.repos.compareCommits({ 142 | owner, 143 | repo, 144 | base, 145 | head, 146 | per_page: 100, 147 | page, 148 | }); 149 | commits.push(...data.commits); 150 | if (data.commits.length < 100) break; 151 | page++; 152 | } 153 | 154 | const result = commits.reverse(); 155 | this.saveToCache(cacheKey, result); 156 | return result; 157 | } 158 | 159 | private parseCommits(commits: any[]): ParsedCommit[] { 160 | return commits.map((commit) => { 161 | const message = commit.commit.message; 162 | const firstLine = message.split("\n")[0].trim(); 163 | 164 | const conventionalRegex = /^(\w+)(?:\(([^)]+)\))?(?:!)?: (.+)/; 165 | const match = firstLine.match(conventionalRegex); 166 | 167 | if (match) { 168 | const [, type, scope, subject] = match; 169 | return { 170 | type: type.toLowerCase(), 171 | scope, 172 | subject, 173 | body: message.split("\n\n")[1], 174 | sha: commit.sha, 175 | url: commit.html_url, 176 | author: commit.author?.login, 177 | authorUrl: commit.author?.html_url, 178 | }; 179 | } 180 | 181 | return { 182 | type: "other", 183 | subject: firstLine, 184 | body: message.split("\n\n")[1], 185 | sha: commit.sha, 186 | url: commit.html_url, 187 | author: commit.author?.login, 188 | authorUrl: commit.author?.html_url, 189 | }; 190 | }); 191 | } 192 | 193 | private generateChangelogFromCommits( 194 | commits: ParsedCommit[], 195 | tags: TagPair, 196 | options: ChangelogOptions 197 | ): string { 198 | const types: CommitType[] = [ 199 | { types: ["feat"], header: "New Features", icon: ":sparkles:" }, 200 | { types: ["fix"], header: "Bug Fixes", icon: ":bug:" }, 201 | { types: ["perf"], header: "Performance", icon: ":zap:" }, 202 | { types: ["refactor"], header: "Refactors", icon: ":recycle:" }, 203 | { types: ["test"], header: "Tests", icon: ":white_check_mark:" }, 204 | { 205 | types: ["build", "ci"], 206 | header: "Build", 207 | icon: ":construction_worker:", 208 | }, 209 | { types: ["docs"], header: "Documentation", icon: ":memo:" }, 210 | { types: ["other"], header: "Other Changes", icon: ":flying_saucer:" }, 211 | ]; 212 | 213 | const sections: string[] = []; 214 | const processedCommits = new Set(); 215 | 216 | if (options.reverseOrder) commits.reverse(); 217 | 218 | for (const type of types) { 219 | if ( 220 | type.types[0] !== "other" && 221 | options.excludeTypes?.includes(type.types[0]) 222 | ) 223 | continue; 224 | 225 | const typeCommits = commits.filter( 226 | (c) => 227 | type.types.includes(c.type || "other") && 228 | (!options.excludeScopes?.length || 229 | !c.scope || 230 | !options.excludeScopes.includes(c.scope)) 231 | ); 232 | 233 | if (typeCommits.length) { 234 | const header = options.useGitmojis 235 | ? `### ${type.icon} ${type.header}` 236 | : `### ${type.header}`; 237 | const entries = typeCommits.map((commit) => { 238 | processedCommits.add(commit.sha); 239 | const scope = commit.scope ? `**${commit.scope}**: ` : ""; 240 | const author = commit.author 241 | ? ` by [@${commit.author}](${commit.authorUrl})` 242 | : ""; 243 | return `- [\`${commit.sha.substring(0, 7)}\`](${ 244 | commit.url 245 | }) - ${scope}${commit.subject}${author}`; 246 | }); 247 | sections.push(`${header}\n${entries.join("\n")}`); 248 | } 249 | } 250 | 251 | const date = new Date().toISOString().split("T")[0]; 252 | const version = `## [${tags.latest}] - ${date}`; 253 | return `${version}\n\n${sections.join("\n\n")}\n`; 254 | } 255 | 256 | private parseGithubUrl(url: string): { owner: string; repo: string } { 257 | const regex = /github\.com\/([^/]+)\/([^/]+)/; 258 | const match = url.match(regex); 259 | if (!match) throw new Error("Invalid GitHub URL"); 260 | return { owner: match[1], repo: match[2].replace(/\.git$/, "") }; 261 | } 262 | 263 | async generate( 264 | options: ChangelogOptions 265 | ): Promise<{ changelog: string; fromTag: string; toTag: string }> { 266 | const cacheKey = this.getCacheKey("generate", options); 267 | const cached = this.getFromCache<{ 268 | changelog: string; 269 | fromTag: string; 270 | toTag: string; 271 | }>(cacheKey); 272 | if (cached) return cached; 273 | 274 | const { owner, repo } = this.parseGithubUrl(options.githubUrl); 275 | const tags = await this.getTags(owner, repo); 276 | const base = options.fromTag || tags.previous; 277 | const head = options.toTag || tags.latest; 278 | const commits = await this.getCommits(owner, repo, head, base); 279 | 280 | if (commits.length === 0) { 281 | const result = { 282 | changelog: "No changes found between these versions.", 283 | fromTag: base, 284 | toTag: head, 285 | }; 286 | this.saveToCache(cacheKey, result); 287 | return result; 288 | } 289 | 290 | const parsedCommits = this.parseCommits(commits); 291 | const result = { 292 | changelog: this.generateChangelogFromCommits( 293 | parsedCommits, 294 | { latest: head, previous: base }, 295 | options 296 | ), 297 | fromTag: base, 298 | toTag: head, 299 | }; 300 | 301 | this.saveToCache(cacheKey, result); 302 | return result; 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 325 | 326 | 630 | --------------------------------------------------------------------------------