Skip to content

Commit 3d81841

Browse files
committed
Changelog script adapted from bookface person's script
https://bookface.ycombinator.com/posts/90968
1 parent 8b94bb6 commit 3d81841

File tree

1 file changed

+301
-0
lines changed

1 file changed

+301
-0
lines changed

scripts/get-changelog.ts

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { execSync } from 'child_process'
2+
import fs from 'fs'
3+
import path from 'path'
4+
import prettier from 'prettier'
5+
import { promptAiSdk } from '../backend/src/llm-apis/vercel-ai-sdk/ai-sdk'
6+
import { models } from 'common/constants'
7+
import { generateCompactId } from 'common/util/string'
8+
9+
// Native slugify implementation
10+
function slugify(text: string): string {
11+
return text
12+
.toLowerCase()
13+
.trim()
14+
.replace(/[^\w\s-]/g, '') // Remove special characters
15+
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
16+
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
17+
}
18+
19+
// Helper functions for date manipulation
20+
function getWeekStart(date: Date): Date {
21+
const d = new Date(date)
22+
const day = d.getDay()
23+
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Monday as start of week
24+
d.setDate(diff)
25+
d.setHours(0, 0, 0, 0)
26+
return d
27+
}
28+
29+
function getWeekEnd(date: Date): Date {
30+
const d = new Date(date)
31+
const day = d.getDay()
32+
const diff = d.getDate() - day + (day === 0 ? 0 : 7) // Sunday as end of week
33+
d.setDate(diff)
34+
d.setHours(23, 59, 59, 999)
35+
return d
36+
}
37+
38+
function subtractWeeks(date: Date, weeks: number): Date {
39+
const d = new Date(date)
40+
d.setDate(d.getDate() - weeks * 7)
41+
return d
42+
}
43+
44+
function formatDate(date: Date, format: string): string {
45+
const year = date.getFullYear()
46+
const month = String(date.getMonth() + 1).padStart(2, '0')
47+
const day = String(date.getDate()).padStart(2, '0')
48+
49+
if (format === 'yyyy-MM-dd') {
50+
return `${year}-${month}-${day}`
51+
}
52+
if (format === 'MMMM d') {
53+
const monthNames = [
54+
'January',
55+
'February',
56+
'March',
57+
'April',
58+
'May',
59+
'June',
60+
'July',
61+
'August',
62+
'September',
63+
'October',
64+
'November',
65+
'December',
66+
]
67+
return `${monthNames[date.getMonth()]} ${date.getDate()}`
68+
}
69+
return date.toISOString()
70+
}
71+
72+
function getWeekNumber(date: Date): number {
73+
const d = new Date(date)
74+
d.setHours(0, 0, 0, 0)
75+
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7))
76+
const week1 = new Date(d.getFullYear(), 0, 4)
77+
return (
78+
1 +
79+
Math.round(
80+
((d.getTime() - week1.getTime()) / 86400000 -
81+
3 +
82+
((week1.getDay() + 6) % 7)) /
83+
7
84+
)
85+
)
86+
}
87+
88+
async function generateChangelog(end: Date) {
89+
// Fix: The start should be the beginning of the same week as 'end', not a week before
90+
const start = getWeekStart(end)
91+
92+
// Format dates for git log command
93+
const startDate = formatDate(start, 'yyyy-MM-dd')
94+
const endDate = formatDate(end, 'yyyy-MM-dd')
95+
96+
console.log(`\n📅 Processing week ending ${endDate}...`)
97+
98+
// Check if changelog already exists for this week
99+
const changelogDir = path.join(process.cwd(), 'changelog')
100+
fs.mkdirSync(changelogDir, { recursive: true })
101+
const existingFiles = fs.readdirSync(changelogDir)
102+
const existingChangelog = existingFiles.find((file) =>
103+
file.startsWith(endDate)
104+
)
105+
106+
if (existingChangelog) {
107+
console.log(
108+
`⏭️ Changelog already exists for week ending ${endDate}, skipping...`
109+
)
110+
return true // Return true to indicate we should continue
111+
}
112+
113+
try {
114+
// First, get all commit hashes and titles
115+
console.log(`🔍 Fetching commits from ${startDate} to ${endDate}...`)
116+
const commitsCommand = `git log --pretty=format:"%h : %s" --since="${startDate}" --until="${endDate}"`
117+
const commits = execSync(commitsCommand, { encoding: 'utf-8' })
118+
.split('\n')
119+
.filter((line) => line.trim())
120+
121+
if (commits.length === 0) {
122+
console.log(`💤 No commits found for week ending ${endDate}`)
123+
return false // Return false to indicate we found a week with no commits
124+
}
125+
126+
console.log(`📝 Found ${commits.length} commits, processing details...`)
127+
128+
// Process each commit
129+
const processedCommits: string[] = []
130+
for (const commit of commits) {
131+
const [hash] = commit.split(' : ')
132+
133+
// Get full commit details including body, without diff
134+
const detailsCommand = `git show ${hash} --pretty=format:"%B" --no-patch`
135+
const details = execSync(detailsCommand, { encoding: 'utf-8' })
136+
.split('\n')
137+
.filter((line) => line.trim())
138+
// Remove the first line (commit message) and any empty lines
139+
.slice(1)
140+
.filter((line) => line.trim())
141+
// Split by bullet points and format each one
142+
.map((line) => line.replace(/^-+\s*/, ''))
143+
.filter((line) => line.trim())
144+
.map((line) => ` - ${line}`)
145+
.join('\n')
146+
147+
processedCommits.push(`${commit}${details ? '\n\n' + details : ''}`)
148+
}
149+
150+
console.log(`🤖 Generating changelog content with AI...`)
151+
152+
const prompt = `You are a technical writer creating a changelog for a software project. Based on the following git commits, create a well-structured changelog entry.
153+
154+
Git commits:
155+
${processedCommits.join('\n\n')}
156+
157+
Please create a changelog with:
158+
1. A descriptive title for this week's changes
159+
2. Well-organized sections (e.g., Features, Bug Fixes, Improvements, etc.)
160+
3. Clear, user-friendly descriptions of changes
161+
4. Proper markdown formatting
162+
163+
Start your response with a heading using ### (three hashes) and organize the content below it.`
164+
165+
const response = await promptAiSdk({
166+
messages: [{ role: 'user', content: prompt }],
167+
clientSessionId: generateCompactId(),
168+
fingerprintId: generateCompactId(),
169+
userInputId: generateCompactId(),
170+
model: models.sonnet,
171+
userId: undefined,
172+
chargeUser: false,
173+
})
174+
175+
// Clean up the AI response
176+
console.log(`🧹 Cleaning up AI response...`)
177+
const cleanedText = response
178+
// Remove everything before the first ###
179+
.replace(/^[\s\S]*?(?=###)/, '')
180+
// Replace ### with ##
181+
.replace(/###/g, '##')
182+
183+
// Extract the first heading for the filename
184+
const firstHeading = cleanedText.match(/^##\s+(.+)$/m)?.[1] ?? 'changelog'
185+
const filename = `${endDate}-${slugify(firstHeading)}.mdx`
186+
187+
// Remove the first heading if it matches the title
188+
const contentWithoutFirstHeading = cleanedText
189+
.replace(/^##\s+.+$/m, '')
190+
.trim()
191+
192+
// Create changelog content with front matter
193+
const changelogContent = `---
194+
title: "${firstHeading}"
195+
description: "Week ${getWeekNumber(start)}, ${start.getFullYear()}${formatDate(start, 'MMMM d')} to ${formatDate(end, 'MMMM d')}"
196+
---
197+
198+
${contentWithoutFirstHeading}`
199+
200+
// Write to the changelog file
201+
const changelogPath = path.join(changelogDir, filename)
202+
fs.writeFileSync(
203+
changelogPath,
204+
await prettier.format(changelogContent, { parser: 'markdown' })
205+
)
206+
207+
console.log(`✨ Successfully generated changelog at ${changelogPath}`)
208+
return true // Return true to indicate we should continue
209+
} catch (error) {
210+
console.error(
211+
`❌ Error generating changelog for week ending ${endDate}:`,
212+
error
213+
)
214+
return true // Return true to continue even if there's an error
215+
}
216+
}
217+
218+
async function updateDocsJsonWithChangelogs() {
219+
const docsJsonPath = path.join(process.cwd(), 'changelog', 'docs.json')
220+
const changelogDir = path.join(process.cwd(), 'changelog')
221+
const changelogFiles = fs
222+
.readdirSync(changelogDir)
223+
.filter((file) => file.endsWith('.mdx'))
224+
.sort()
225+
226+
// Group changelogs by quarter
227+
const changelogGroups: { [key: string]: string[] } = {}
228+
for (const file of changelogFiles) {
229+
const match = file.match(/(\d{4})-(\d{2})-\d{2}-.+/)
230+
if (!match) continue
231+
const year = match[1]
232+
const month = parseInt(match[2], 10)
233+
let quarter = 'Q1'
234+
if (month >= 10) quarter = 'Q4'
235+
else if (month >= 7) quarter = 'Q3'
236+
else if (month >= 4) quarter = 'Q2'
237+
// else Q1
238+
const groupName = `${quarter} ${year}`
239+
if (!changelogGroups[groupName]) changelogGroups[groupName] = []
240+
changelogGroups[groupName].push(`changelog/${file.replace(/\.mdx$/, '')}`)
241+
}
242+
243+
// Read and parse docs.json
244+
let docsJson: any
245+
try {
246+
docsJson = JSON.parse(fs.readFileSync(docsJsonPath, 'utf-8'))
247+
} catch (error) {
248+
docsJson = { navigation: { tabs: [{ tab: 'Changelog', groups: [] }] } }
249+
}
250+
const tabs: { tab: string; groups: { group: string; pages: string[] }[] }[] =
251+
docsJson.navigation.tabs
252+
const changelogTab = tabs.find((tab) => tab.tab === 'Changelog')
253+
if (!changelogTab) return
254+
255+
// Replace groups with new changelog groups, sorted by most recent year and quarter (Q4, Q3, Q2, Q1)
256+
changelogTab.groups = Object.entries(changelogGroups)
257+
.sort((a, b) => {
258+
// a[0] and b[0] are like 'Q3 2024'
259+
const [qa, ya] = a[0].split(' ')
260+
const [qb, yb] = b[0].split(' ')
261+
// Sort by year descending
262+
if (yb !== ya) return parseInt(yb) - parseInt(ya)
263+
// Sort by quarter: Q4 > Q3 > Q2 > Q1
264+
const quarterOrder: { [key: string]: number } = {
265+
Q4: 4,
266+
Q3: 3,
267+
Q2: 2,
268+
Q1: 1,
269+
}
270+
return quarterOrder[qb] - quarterOrder[qa]
271+
})
272+
.map(([group, pages]) => ({
273+
group,
274+
pages: (pages as string[]).sort((a, b) => b.localeCompare(a)), // latest post on top
275+
}))
276+
277+
// Write back to docs.json
278+
fs.writeFileSync(docsJsonPath, JSON.stringify(docsJson, null, 2) + '\n')
279+
console.log('✅ Updated docs.json with latest changelogs grouped by quarter.')
280+
}
281+
282+
async function generateAllChangelogs() {
283+
console.log(`🚀 Starting changelog generation...`)
284+
// Start from the end of last week, not the current week
285+
let currentWeek = getWeekEnd(subtractWeeks(new Date(), 1))
286+
287+
// Only generate changelogs for 3 weeks as a test
288+
for (let i = 0; i < 3; i++) {
289+
await generateChangelog(currentWeek)
290+
currentWeek = subtractWeeks(currentWeek, 1)
291+
}
292+
293+
await updateDocsJsonWithChangelogs()
294+
295+
console.log(`🎉 Finished generating up to 3 changelogs!`)
296+
console.log(
297+
`📅 Stopped at week ending ${formatDate(currentWeek, 'yyyy-MM-dd')}`
298+
)
299+
}
300+
301+
generateAllChangelogs()

0 commit comments

Comments
 (0)