|
| 1 | +name: AI Changelog Generator |
| 2 | + |
| 3 | +on: |
| 4 | + release: |
| 5 | + types: [published] |
| 6 | + workflow_dispatch: |
| 7 | + inputs: |
| 8 | + ai_model: |
| 9 | + description: "OpenRouter model to use" |
| 10 | + required: false |
| 11 | + default: "anthropic/claude-sonnet-4" |
| 12 | + test_mode: |
| 13 | + description: "Test mode (use last 10 commits instead of since last release)" |
| 14 | + required: false |
| 15 | + default: "false" |
| 16 | + type: boolean |
| 17 | + |
| 18 | +permissions: |
| 19 | + contents: write |
| 20 | + |
| 21 | +jobs: |
| 22 | + generate-changelog: |
| 23 | + runs-on: ubuntu-latest |
| 24 | + timeout-minutes: 30 |
| 25 | + |
| 26 | + steps: |
| 27 | + - name: Checkout repository |
| 28 | + uses: actions/checkout@v4 |
| 29 | + with: |
| 30 | + fetch-depth: 0 |
| 31 | + |
| 32 | + - name: Setup Node.js |
| 33 | + uses: actions/setup-node@v4 |
| 34 | + with: |
| 35 | + node-version: "20" |
| 36 | + cache: "npm" |
| 37 | + |
| 38 | + - name: Install dependencies |
| 39 | + run: npm install axios @octokit/rest |
| 40 | + |
| 41 | + - name: Generate changelog |
| 42 | + id: changelog |
| 43 | + env: |
| 44 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 45 | + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} |
| 46 | + AI_MODEL: ${{ (github.event.inputs && github.event.inputs.ai_model) || 'anthropic/claude-sonnet-4' }} |
| 47 | + TEST_MODE: ${{ (github.event.inputs && github.event.inputs.test_mode) || 'false' }} |
| 48 | + run: | |
| 49 | + cat > changelog-generator.js << 'EOF' |
| 50 | + const { Octokit } = require('@octokit/rest'); |
| 51 | + const axios = require('axios'); |
| 52 | + const fs = require('fs'); |
| 53 | +
|
| 54 | + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); |
| 55 | + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); |
| 56 | + const testMode = process.env.TEST_MODE === 'true'; |
| 57 | +
|
| 58 | + async function getCommits() { |
| 59 | + try { |
| 60 | + if (testMode) { |
| 61 | + console.log('TEST MODE: Getting last 10 commits'); |
| 62 | + const { data } = await octokit.rest.repos.listCommits({ |
| 63 | + owner, repo, per_page: 10 |
| 64 | + }); |
| 65 | + return data; |
| 66 | + } |
| 67 | +
|
| 68 | + // Get releases and compare |
| 69 | + const { data: releases } = await octokit.rest.repos.listReleases({ |
| 70 | + owner, repo, per_page: 2 |
| 71 | + }); |
| 72 | +
|
| 73 | + if (releases.length < 2) { |
| 74 | + console.log('Not enough releases, getting last 50 commits'); |
| 75 | + const { data } = await octokit.rest.repos.listCommits({ |
| 76 | + owner, repo, per_page: 50 |
| 77 | + }); |
| 78 | + return data; |
| 79 | + } |
| 80 | +
|
| 81 | + const [current, previous] = releases; |
| 82 | + console.log(`Getting commits between ${previous.tag_name} and ${current.tag_name}`); |
| 83 | +
|
| 84 | + const { data: comparison } = await octokit.rest.repos.compareCommits({ |
| 85 | + owner, repo, base: previous.tag_name, head: current.tag_name |
| 86 | + }); |
| 87 | +
|
| 88 | + return comparison.commits; |
| 89 | + } catch (error) { |
| 90 | + console.error('Error getting commits:', error.message); |
| 91 | + const { data } = await octokit.rest.repos.listCommits({ |
| 92 | + owner, repo, per_page: 50 |
| 93 | + }); |
| 94 | + return data; |
| 95 | + } |
| 96 | + } |
| 97 | +
|
| 98 | + async function getCommitDetails(sha) { |
| 99 | + try { |
| 100 | + const { data } = await octokit.rest.repos.getCommit({ |
| 101 | + owner, repo, ref: sha |
| 102 | + }); |
| 103 | + return data; |
| 104 | + } catch (error) { |
| 105 | + console.error(`Error getting commit details for ${sha}:`, error.message); |
| 106 | + return null; |
| 107 | + } |
| 108 | + } |
| 109 | +
|
| 110 | + async function generateChangelog(commits, commitDetails) { |
| 111 | + const prompt = `You are a technical writer creating a changelog for hyprnote - a desktop note-taking app with AI capabilities (Tauri + React). |
| 112 | +
|
| 113 | + Create a professional changelog with: |
| 114 | + - Categories: Breaking Changes, Features, Improvements, Bug Fixes, Internal, Dependencies |
| 115 | + - No emojis or casual language |
| 116 | + - Focus on functional impact and technical details |
| 117 | + - Reference specific components when applicable |
| 118 | +
|
| 119 | + COMMITS: |
| 120 | + ${JSON.stringify(commits.map(c => ({ |
| 121 | + sha: c.sha.substring(0, 8), |
| 122 | + message: c.commit.message, |
| 123 | + author: c.commit.author.name, |
| 124 | + date: c.commit.author.date |
| 125 | + })), null, 2)} |
| 126 | +
|
| 127 | + DETAILED CHANGES: |
| 128 | + ${commitDetails.map(detail => { |
| 129 | + if (!detail) return 'Commit details unavailable'; |
| 130 | + return ` |
| 131 | + Commit: ${detail.sha.substring(0, 8)} |
| 132 | + Message: ${detail.commit.message} |
| 133 | + Files changed: ${detail.files ? detail.files.length : 0} |
| 134 | + ${detail.files ? detail.files.map(f => `- ${f.filename} (+${f.additions} -${f.deletions})`).join('\n') : ''} |
| 135 | + `; |
| 136 | + }).join('\n---\n')} |
| 137 | +
|
| 138 | + Generate a markdown changelog suitable for release notes.`; |
| 139 | +
|
| 140 | + const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', { |
| 141 | + model: process.env.AI_MODEL, |
| 142 | + messages: [ |
| 143 | + { |
| 144 | + role: 'system', |
| 145 | + content: 'You are a technical writer specializing in software documentation. Generate precise, professional changelog content.' |
| 146 | + }, |
| 147 | + { role: 'user', content: prompt } |
| 148 | + ], |
| 149 | + max_tokens: 4000, |
| 150 | + temperature: 0.1 |
| 151 | + }, { |
| 152 | + headers: { |
| 153 | + 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`, |
| 154 | + 'Content-Type': 'application/json', |
| 155 | + 'HTTP-Referer': 'https://github.com/hyprnote/hyprnote', |
| 156 | + 'X-Title': 'hyprnote AI Changelog Generator' |
| 157 | + } |
| 158 | + }); |
| 159 | +
|
| 160 | + return response.data.choices[0].message.content; |
| 161 | + } |
| 162 | +
|
| 163 | + async function main() { |
| 164 | + try { |
| 165 | + console.log('Starting changelog generation...'); |
| 166 | +
|
| 167 | + const commits = await getCommits(); |
| 168 | + console.log(`Found ${commits.length} commits to analyze`); |
| 169 | +
|
| 170 | + if (commits.length === 0) { |
| 171 | + console.log('No commits found'); |
| 172 | + return; |
| 173 | + } |
| 174 | +
|
| 175 | + console.log('Fetching detailed commit information...'); |
| 176 | + const commitDetails = await Promise.all( |
| 177 | + commits.slice(0, 20).map(commit => getCommitDetails(commit.sha)) |
| 178 | + ); |
| 179 | +
|
| 180 | + console.log('Generating changelog with AI...'); |
| 181 | + const commitsToProcess = commits.slice(0, 20); |
| 182 | + const changelog = await generateChangelog(commitsToProcess, commitDetails); |
| 183 | +
|
| 184 | + fs.writeFileSync('changelog.md', changelog); |
| 185 | +
|
| 186 | + const metadata = { |
| 187 | + generated_at: new Date().toISOString(), |
| 188 | + commit_count: commits.length, |
| 189 | + commits_processed: commitsToProcess.length, |
| 190 | + model_used: process.env.AI_MODEL, |
| 191 | + test_mode: testMode, |
| 192 | + repository: `${owner}/${repo}`, |
| 193 | + commits_analyzed: commitsToProcess.map(c => ({ |
| 194 | + sha: c.sha.substring(0, 8), |
| 195 | + message: c.commit.message.split('\n')[0], |
| 196 | + author: c.commit.author.name, |
| 197 | + date: c.commit.author.date |
| 198 | + })) |
| 199 | + }; |
| 200 | +
|
| 201 | + fs.writeFileSync('metadata.json', JSON.stringify(metadata, null, 2)); |
| 202 | +
|
| 203 | + console.log('Changelog generated successfully'); |
| 204 | + console.log('Preview:'); |
| 205 | + console.log(changelog.substring(0, 500) + '...'); |
| 206 | +
|
| 207 | + } catch (error) { |
| 208 | + console.error('Error in main:', error); |
| 209 | + process.exit(1); |
| 210 | + } |
| 211 | + } |
| 212 | +
|
| 213 | + main(); |
| 214 | + EOF |
| 215 | +
|
| 216 | + node changelog-generator.js |
| 217 | +
|
| 218 | + - name: Generate changelog file |
| 219 | + run: | |
| 220 | + # Create a timestamped changelog file |
| 221 | + TIMESTAMP=$(date -u +"%Y%m%d_%H%M%S") |
| 222 | + FILENAME="CHANGELOG_${TIMESTAMP}.md" |
| 223 | +
|
| 224 | + # Add header with metadata |
| 225 | + cat > "$FILENAME" << EOF |
| 226 | + # Changelog Generated $(date -u) |
| 227 | +
|
| 228 | + **Generation Details:** |
| 229 | + - Generated: $(date -u) |
| 230 | + - Trigger: ${{ github.event_name == 'release' && 'Release Published' || 'Manual Trigger' }} |
| 231 | + - Model: ${{ (github.event.inputs && github.event.inputs.ai_model) || 'anthropic/claude-sonnet-4' }} |
| 232 | + - Test Mode: ${{ (github.event.inputs && github.event.inputs.test_mode) || 'false' }} |
| 233 | + - Repository: ${{ github.repository }} |
| 234 | + - Workflow Run: [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |
| 235 | +
|
| 236 | + --- |
| 237 | +
|
| 238 | + EOF |
| 239 | +
|
| 240 | + # Append the AI-generated changelog |
| 241 | + cat changelog.md >> "$FILENAME" |
| 242 | +
|
| 243 | + # Set output for commit step |
| 244 | + echo "changelog_file=$FILENAME" >> $GITHUB_OUTPUT |
| 245 | +
|
| 246 | + echo "Generated changelog file: $FILENAME" |
| 247 | + echo "Preview:" |
| 248 | + head -50 "$FILENAME" |
| 249 | + id: generate_file |
| 250 | + |
| 251 | + - name: Commit changelog file |
| 252 | + run: | |
| 253 | + git config --local user.email "action@github.com" |
| 254 | + git config --local user.name "GitHub Action" |
| 255 | +
|
| 256 | + # Add the generated file |
| 257 | + git add "${{ steps.generate_file.outputs.changelog_file }}" |
| 258 | +
|
| 259 | + # Only commit if there are changes |
| 260 | + if git diff --staged --quiet; then |
| 261 | + echo "No changes to commit" |
| 262 | + else |
| 263 | + git commit -m "Add AI-generated changelog |
| 264 | +
|
| 265 | + Generated: $(date -u) |
| 266 | + Model: ${{ (github.event.inputs && github.event.inputs.ai_model) || 'anthropic/claude-sonnet-4' }} |
| 267 | + Test Mode: ${{ (github.event.inputs && github.event.inputs.test_mode) || 'false' }} |
| 268 | +
|
| 269 | + 🤖 Generated with AI Changelog Workflow |
| 270 | +
|
| 271 | + Co-Authored-By: GitHub Action <action@github.com>" |
| 272 | +
|
| 273 | + git push |
| 274 | +
|
| 275 | + echo "Changelog committed as: ${{ steps.generate_file.outputs.changelog_file }}" |
| 276 | + fi |
| 277 | +
|
| 278 | + - name: Upload artifacts |
| 279 | + uses: actions/upload-artifact@v4 |
| 280 | + with: |
| 281 | + name: changelog-artifacts |
| 282 | + path: | |
| 283 | + changelog.md |
| 284 | + metadata.json |
| 285 | + retention-days: 30 |
0 commit comments