devex: changelog system (#5309)
* devex: changelog system * feat: changelog CIs * feat: web alias for platform + hosting * feat: upload binaries to gh release * feat: improve copy text * fix: release workflow * fix: changelog CIs + PR health check comment * fix: action * fix: comment style * fix: comment * fix: remove health * fix: deploy use Modrinth bot machine account * feat: new system * fix: pr comment structure
This commit is contained in:
425
scripts/collect-changelog.ts
Normal file
425
scripts/collect-changelog.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { execSync } from 'child_process'
|
||||
import chalk from 'chalk'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
type Product = 'web' | 'app' | 'hosting'
|
||||
|
||||
const CHANGELOG_MARKER = '<!-- changelog -->'
|
||||
const SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const
|
||||
const SECTION_HEADERS: Record<string, string> = {
|
||||
added: '## Added',
|
||||
changed: '## Changed',
|
||||
deprecated: '## Deprecated',
|
||||
removed: '## Removed',
|
||||
fixed: '## Fixed',
|
||||
security: '## Security',
|
||||
}
|
||||
|
||||
const PRODUCT_SUMMARY_MAP: Record<string, Product> = {
|
||||
App: 'app',
|
||||
Website: 'web',
|
||||
Hosting: 'hosting',
|
||||
}
|
||||
|
||||
const GITHUB_API = 'https://api.github.com'
|
||||
const REPO = 'modrinth/code'
|
||||
|
||||
interface ParsedChangelog {
|
||||
entries: Map<Product, Map<string, string[]>>
|
||||
}
|
||||
|
||||
interface PRInfo {
|
||||
number: number
|
||||
mergedAt: string
|
||||
}
|
||||
|
||||
interface CommentInfo {
|
||||
id: number
|
||||
body: string
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
version?: string
|
||||
dryRun: boolean
|
||||
} {
|
||||
let version: string | undefined
|
||||
let dryRun = false
|
||||
|
||||
let i = 0
|
||||
while (i < argv.length) {
|
||||
if (argv[i] === '--') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (argv[i] === '--version') {
|
||||
i++
|
||||
if (i >= argv.length || argv[i].startsWith('--')) {
|
||||
console.error(chalk.red('--version requires a value'))
|
||||
process.exit(1)
|
||||
}
|
||||
version = argv[i]
|
||||
i++
|
||||
} else if (argv[i] === '--dry-run') {
|
||||
dryRun = true
|
||||
i++
|
||||
} else {
|
||||
console.error(chalk.red(`Unknown argument: ${argv[i]}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
return { version, dryRun }
|
||||
}
|
||||
|
||||
async function getParser() {
|
||||
const mod = await import('keep-a-changelog')
|
||||
return mod.parser
|
||||
}
|
||||
|
||||
function parseChangelogComment(body: string, parse: Function): ParsedChangelog | null {
|
||||
if (!body.includes(CHANGELOG_MARKER)) return null
|
||||
|
||||
const entries = new Map<Product, Map<string, string[]>>()
|
||||
|
||||
const detailsRegex = /<details>\s*<summary>(.*?)<\/summary>([\s\S]*?)<\/details>/g
|
||||
let match
|
||||
while ((match = detailsRegex.exec(body)) !== null) {
|
||||
const summaryLabel = match[1].trim()
|
||||
const product = PRODUCT_SUMMARY_MAP[summaryLabel]
|
||||
if (!product) continue
|
||||
|
||||
const content = match[2].replace(/<!--[\s\S]*?-->/g, '').trim()
|
||||
const firstSection = content.search(/^### /m)
|
||||
if (firstSection === -1) continue
|
||||
|
||||
const changelogMd = `# Changelog\n\n## [Unreleased]\n${content.slice(firstSection)}`
|
||||
|
||||
let changelog
|
||||
try {
|
||||
changelog = parse(changelogMd)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const unreleased = changelog.findRelease()
|
||||
if (!unreleased || unreleased.isEmpty()) continue
|
||||
|
||||
const sections = new Map<string, string[]>()
|
||||
for (const type of SECTION_ORDER) {
|
||||
const changes = unreleased.changes.get(type)
|
||||
if (changes && changes.length > 0) {
|
||||
sections.set(type, changes.map((c: { title: string }) => c.title))
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.size > 0) {
|
||||
entries.set(product, sections)
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.size === 0) return null
|
||||
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function linkifyIssues(text: string): string {
|
||||
return text.replace(
|
||||
/\[#(\d+)\]/g,
|
||||
'[#$1](https://github.com/modrinth/code/issues/$1)',
|
||||
)
|
||||
}
|
||||
|
||||
function buildBody(sections: Map<string, string[]>): string {
|
||||
const parts: string[] = []
|
||||
|
||||
for (const type of SECTION_ORDER) {
|
||||
const entries = sections.get(type)
|
||||
if (!entries || entries.length === 0) continue
|
||||
|
||||
const lines = entries.map((e) => `- ${linkifyIssues(e)}`)
|
||||
parts.push(`${SECTION_HEADERS[type]}\n${lines.join('\n')}`)
|
||||
}
|
||||
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function mergeSections(
|
||||
allEntries: { sections: Map<string, string[]>; prNumber: number }[],
|
||||
): Map<string, string[]> {
|
||||
const merged = new Map<string, string[]>()
|
||||
|
||||
for (const { sections } of allEntries) {
|
||||
for (const [type, entries] of sections) {
|
||||
if (!merged.has(type)) {
|
||||
merged.set(type, [])
|
||||
}
|
||||
merged.get(type)!.push(...entries)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
async function githubFetch(endpoint: string, token: string, options?: RequestInit): Promise<Response> {
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${GITHUB_API}${endpoint}`
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchMergedPRs(token: string, sinceDate: string): Promise<PRInfo[]> {
|
||||
const prs: PRInfo[] = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const res = await githubFetch(
|
||||
`/repos/${REPO}/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`,
|
||||
token,
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(chalk.red(`GitHub API error: ${res.status} ${res.statusText}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const data = (await res.json()) as Array<{
|
||||
number: number
|
||||
merged_at: string | null
|
||||
updated_at: string
|
||||
}>
|
||||
|
||||
if (data.length === 0) break
|
||||
|
||||
let foundOlder = false
|
||||
for (const pr of data) {
|
||||
if (!pr.merged_at) continue
|
||||
|
||||
if (new Date(pr.merged_at) > new Date(sinceDate)) {
|
||||
prs.push({ number: pr.number, mergedAt: pr.merged_at })
|
||||
} else {
|
||||
foundOlder = true
|
||||
}
|
||||
}
|
||||
|
||||
if (foundOlder || data.length < 100) break
|
||||
page++
|
||||
}
|
||||
|
||||
return prs
|
||||
}
|
||||
|
||||
async function fetchBotComment(token: string, prNumber: number): Promise<CommentInfo | null> {
|
||||
const res = await githubFetch(
|
||||
`/repos/${REPO}/issues/${prNumber}/comments?per_page=100`,
|
||||
token,
|
||||
)
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const comments = (await res.json()) as Array<{ id: number; body: string }>
|
||||
const comment = comments.find((c) => c.body.includes(CHANGELOG_MARKER))
|
||||
|
||||
if (!comment) return null
|
||||
return { id: comment.id, body: comment.body }
|
||||
}
|
||||
|
||||
async function markCommentBaked(token: string, commentId: number, currentBody: string): Promise<void> {
|
||||
const admonition = '> [!NOTE]\n> This changelog has been baked. Any further edits will not be reflected.\n\n'
|
||||
const newBody = admonition + currentBody
|
||||
|
||||
await githubFetch(`/repos/${REPO}/issues/comments/${commentId}`, token, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ body: newBody }),
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentPacificISO(): string {
|
||||
const now = new Date()
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Los_Angeles',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(now)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
|
||||
|
||||
const year = get('year')
|
||||
const month = get('month')
|
||||
const day = get('day')
|
||||
const hour = get('hour')
|
||||
const minute = get('minute')
|
||||
const second = get('second')
|
||||
|
||||
const jan = new Date(now.getFullYear(), 0, 1)
|
||||
const jul = new Date(now.getFullYear(), 6, 1)
|
||||
const stdOffset = Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset())
|
||||
const pacificNow = new Date(
|
||||
now.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }),
|
||||
)
|
||||
const utcNow = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||
const offsetMinutes = (utcNow.getTime() - pacificNow.getTime()) / 60000
|
||||
const isDST = offsetMinutes !== stdOffset
|
||||
|
||||
const offsetStr = isDST ? '-07:00' : '-08:00'
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}${offsetStr}`
|
||||
}
|
||||
|
||||
function generateEntry(product: string, body: string, version?: string): string {
|
||||
const dateStr = getCurrentPacificISO()
|
||||
const versionLine = version ? `\n\t\tversion: '${version}',` : ''
|
||||
|
||||
return `\t{
|
||||
\t\tdate: \`${dateStr}\`,
|
||||
\t\tproduct: '${product}',${versionLine}
|
||||
\t\tbody: \`${body}\`,
|
||||
\t},`
|
||||
}
|
||||
|
||||
function insertIntoChangelog(changelogPath: string, entryString: string): void {
|
||||
const content = fs.readFileSync(changelogPath, 'utf-8')
|
||||
|
||||
const marker = 'const VERSIONS: VersionEntry[] = ['
|
||||
const markerIndex = content.indexOf(marker)
|
||||
|
||||
if (markerIndex === -1) {
|
||||
console.error(chalk.red('Could not find VERSIONS array in changelog.ts'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const insertPos = content.indexOf('\n', markerIndex) + 1
|
||||
|
||||
const newContent = content.slice(0, insertPos) + entryString + '\n' + content.slice(insertPos)
|
||||
|
||||
fs.writeFileSync(changelogPath, newContent, 'utf-8')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
let token = process.env.GITHUB_TOKEN
|
||||
if (!token) {
|
||||
try {
|
||||
token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
|
||||
} catch {
|
||||
console.error(chalk.red('GITHUB_TOKEN not set and `gh auth token` failed. Run `gh auth login` or set GITHUB_TOKEN.'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const parse = await getParser()
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const changelogPath = path.join(rootDir, 'packages', 'blog', 'changelog.ts')
|
||||
|
||||
const prodDate = execSync('git log -1 --format=%aI origin/prod', { encoding: 'utf-8' }).trim()
|
||||
console.log(chalk.gray(`Last prod commit: ${prodDate}`))
|
||||
|
||||
const prs = await fetchMergedPRs(token, prodDate)
|
||||
console.log(chalk.gray(`Found ${prs.length} merged PR(s) since last prod deploy`))
|
||||
|
||||
if (prs.length === 0) {
|
||||
console.log(chalk.yellow('No merged PRs found since last prod deploy'))
|
||||
return
|
||||
}
|
||||
|
||||
const allEntries = new Map<Product, { sections: Map<string, string[]>; prNumber: number }[]>()
|
||||
const processedComments: { commentId: number; body: string }[] = []
|
||||
|
||||
for (const pr of prs) {
|
||||
const comment = await fetchBotComment(token, pr.number)
|
||||
if (!comment) {
|
||||
console.log(chalk.gray(`PR #${pr.number}: no changelog comment, skipping`))
|
||||
continue
|
||||
}
|
||||
|
||||
if (comment.body.includes('> This changelog has been baked.')) {
|
||||
console.log(chalk.gray(`PR #${pr.number}: already baked, skipping`))
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = parseChangelogComment(comment.body, parse)
|
||||
if (!parsed) {
|
||||
console.log(chalk.gray(`PR #${pr.number}: no changelog entries, skipping`))
|
||||
continue
|
||||
}
|
||||
|
||||
const products = [...parsed.entries.keys()]
|
||||
const types = [...new Set([...parsed.entries.values()].flatMap((s) => [...s.keys()]))]
|
||||
console.log(chalk.cyan(`PR #${pr.number}: ${products.join(', ')} — ${types.join(', ')}`))
|
||||
|
||||
for (const [product, sections] of parsed.entries) {
|
||||
if (!allEntries.has(product)) {
|
||||
allEntries.set(product, [])
|
||||
}
|
||||
allEntries.get(product)!.push({ sections, prNumber: pr.number })
|
||||
}
|
||||
|
||||
processedComments.push({ commentId: comment.id, body: comment.body })
|
||||
}
|
||||
|
||||
if (allEntries.size === 0) {
|
||||
console.log(chalk.yellow('No changelog entries found in merged PRs'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasApp = allEntries.has('app')
|
||||
if (hasApp && !args.version) {
|
||||
console.error(chalk.red('--version is required when app changelog entries exist'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const products = [...allEntries.keys()].reverse()
|
||||
for (const product of products) {
|
||||
const productEntries = allEntries.get(product)!
|
||||
const mergedSections = mergeSections(productEntries)
|
||||
const body = buildBody(mergedSections)
|
||||
|
||||
if (body.includes('`')) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: Changelog body for ${product} contains backticks — this may break the template literal in changelog.ts`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const version = product === 'app' ? args.version : undefined
|
||||
const entryString = generateEntry(product, body, version)
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(chalk.cyan(`\n[dry-run] Would insert for ${product}:`))
|
||||
console.log(entryString)
|
||||
} else {
|
||||
insertIntoChangelog(changelogPath, entryString)
|
||||
console.log(chalk.green(`Inserted changelog entry for ${product}`))
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.dryRun) {
|
||||
for (const { commentId, body } of processedComments) {
|
||||
await markCommentBaked(token, commentId, body)
|
||||
}
|
||||
console.log(chalk.gray(`Marked ${processedComments.length} comment(s) as baked`))
|
||||
}
|
||||
|
||||
console.log()
|
||||
if (args.dryRun) {
|
||||
console.log(chalk.cyan('Dry run complete — no changes written'))
|
||||
} else {
|
||||
console.log(chalk.green('Done! Review the changes in packages/blog/changelog.ts'))
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user