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 = '' const SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const const SECTION_HEADERS: Record = { added: '## Added', changed: '## Changed', deprecated: '## Deprecated', removed: '## Removed', fixed: '## Fixed', security: '## Security', } const PRODUCT_SUMMARY_MAP: Record = { App: 'app', Website: 'web', Hosting: 'hosting', } const GITHUB_API = 'https://api.github.com' const REPO = 'modrinth/code' interface ParsedChangelog { entries: Map> } interface PRInfo { number: number mergedAt: string } interface CommentInfo { id: number body: string } function parseArgs(argv: string[]): { version?: string dryRun: boolean pr?: number } { let version: string | undefined let dryRun = false let pr: number | undefined 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 if (argv[i] === '--pr') { i++ if (i >= argv.length || argv[i].startsWith('--')) { console.error(chalk.red('--pr requires a value')) process.exit(1) } pr = parseInt(argv[i], 10) if (isNaN(pr)) { console.error(chalk.red('--pr must be a number')) process.exit(1) } i++ } else { console.error(chalk.red(`Unknown argument: ${argv[i]}`)) process.exit(1) } } return { version, dryRun, pr } } 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>() const detailsRegex = /
\s*(.*?)<\/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(//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() 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 { 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').replace(/`/g, '\\`').replace(/\$/g, '\\$') } function mergeSections( allEntries: { sections: Map; prNumber: number }[], ): Map { const merged = new Map() 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 { 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 { 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 allTooOld = true for (const pr of data) { if (new Date(pr.updated_at) < new Date(sinceDate)) continue allTooOld = false if (!pr.merged_at) continue if (new Date(pr.merged_at) > new Date(sinceDate)) { prs.push({ number: pr.number, mergedAt: pr.merged_at }) } } if (allTooOld || data.length < 100) break page++ } return prs } async function fetchBotComment(token: string, prNumber: number): Promise { 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 { 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 getCurrentUTCISO(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') } function generateEntry(product: string, body: string, version?: string): string { const dateStr = getCurrentUTCISO() 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') let prs: PRInfo[] if (args.pr) { console.log(chalk.gray(`Targeting single PR #${args.pr}`)) prs = [{ number: args.pr, mergedAt: '' }] } else { const prodDate = execSync('git log -1 --format=%aI origin/prod', { encoding: 'utf-8' }).trim() console.log(chalk.gray(`Last prod commit: ${prodDate}`)) 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; 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) 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()