Created
January 16, 2025 20:54
-
-
Save Krinkle/684c766ff33346946b6ce144b1751b64 to your computer and use it in GitHub Desktop.
Convert a markdown changelog to blog posts, compatible with Jekyll and Eleventy. Used by https://qunitjs.com/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-env node */ | |
'use strict'; | |
import cp from 'node:child_process'; | |
import fs from 'node:fs'; | |
import path from 'node:path'; | |
const CHANGELOG_FILE = './History.md'; | |
const MAILMAP_FILE = '.mailmap'; | |
const FIRST_TODO = '2.23.1 / '; | |
const LAST_DONE = '1.3.0 / '; | |
const OUT_POSTS_DIR = 'docs/_posts/'; | |
const OUT_AUTHORS_FILE = 'docs/_data/authors.yaml'; | |
const POST_AUTHOR_OVERRIDE = { | |
// '[email protected]': { username: 'example', name: 'John Doe' } | |
}; | |
/** | |
* @param {boolean} writeFiles False by default, for dry run. | |
*/ | |
async function main (writeFiles = false) { | |
let input = fs.readFileSync(CHANGELOG_FILE).toString(); | |
const userCache = new Map(); | |
const firstIdx = input.indexOf(FIRST_TODO); | |
const lastIdx = input.indexOf(LAST_DONE); | |
if (firstIdx === -1) { throw 'FIRST_TODO not found'; } | |
if (lastIdx === -1) { throw 'LAST_DONE not found'; } | |
input = input.slice(firstIdx, lastIdx); | |
let draft = null; | |
for (const line of input.split('\n')) { | |
if (/^===+$/.test(line.trim())) { | |
continue; | |
} | |
const nextVersion = /^\d[\d.]+/.exec(line)?.[0]; | |
if (nextVersion) { | |
// Flush previous draft | |
if (draft) { | |
savePost(draft, writeFiles); | |
draft = null; | |
} | |
// Start next draft | |
const version = nextVersion; | |
const date = /(?: \/ )([0-9-]+)/.exec(line)[1]; | |
// Set post author to GitHub username | |
// | |
// Run local git commands to find the email address of the release commit author, | |
// and then map that to a GitHub username. | |
// I considered using the GitHub API, but there are problems: | |
// | |
// 1. The refs API list does not provide tag/commit author information. | |
// This requires a separate request for each tag. | |
// https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28 | |
// https://api.github.com/repos/qunitjs/qunit/git/matching-refs/ | |
// | |
// 2. The refs API for individual tags still does not contain GitHub usernames. | |
// https://api.github.com/repos/qunitjs/qunit/git/tags/cf557517b54bbf09c557c63528f303c5e2c3f7f6 | |
// | |
// 3. The users search API is limited to finding users that list their email | |
// address publicly on their profile page, which most people don't. | |
// `https://api.github.com/search/users?q=${encodeURIComponent(email)+in:email}` | |
// | |
// 4. The commits search API is an effective workaround, but is limited | |
// to current email addresses only. | |
// `https://api.github.com/search/commits?q=author-email:${encodeURIComponent(email)}&per_page=1&sort=author-date` | |
// | |
// 6. To make this work realiably, we'd need the above 4 API requests, plus | |
// a local mailmap call to translate potentially old to current addresses. | |
// | |
// Instead of all that, ask local git directly for the author of a tag, | |
// which already has mailmap applied, and then use the API only to map that. | |
// | |
const tagInfo = gitTagInfo(version); | |
let author = POST_AUTHOR_OVERRIDE[tagInfo.email] || userCache.get(tagInfo.email); | |
if (!author) { | |
const searchData = await ghApi(`https://api.github.com/search/users?q=${encodeURIComponent(tagInfo.email)}`); | |
let username = searchData?.items?.[0]?.login; | |
if (searchData.total_count !== 1 || !username) { | |
const commitData = await ghApi(`https://api.github.com/search/commits?q=author-email:${encodeURIComponent(tagInfo.email)}&per_page=1&sort=author-date`); | |
username = commitData?.items?.[0]?.author?.login; | |
if (!username) { | |
console.error(searchData, commitData); | |
throw new Error(`Failed or ambiguous username lookup for ${version} author ${tagInfo.email}`); | |
} | |
} | |
// Use full name from whichever release we see first. | |
// It is the same for all releases, because gitTagInfo() applies mailmap. | |
author = { username: username.toLowerCase(), name: tagInfo.name }; | |
userCache.set(tagInfo.email, author); | |
} | |
draft = { | |
version, | |
date, | |
username: author.username, | |
changelog: '', | |
filename: `${date}-qunit-${version.replaceAll('.', '-')}.md` | |
}; | |
} else if (draft) { | |
draft.changelog += line + '\n'; | |
} else { | |
console.error(line); | |
throw new Error('Cannot process lines outside an active draft'); | |
} | |
} | |
// Flush last draft | |
if (draft) { | |
savePost(draft, writeFiles); | |
draft = null; | |
} | |
saveAuthors(userCache, writeFiles); | |
} | |
function savePost (draft, writeFiles) { | |
for (const key in draft) { | |
if (!draft[key]) { | |
console.error(draft); | |
throw new Error('Invalid draft'); | |
} | |
} | |
const filename = path.join(OUT_POSTS_DIR, draft.filename); | |
const body = `--- | |
layout: post | |
title: "QUnit ${draft.version} Released" | |
author: ${draft.username} | |
tags: | |
- release | |
--- | |
## Changelog | |
${draft.changelog.trim()} | |
## See also | |
* [Git tag: ${draft.version}](https://github.com/qunitjs/qunit/releases/tag/${draft.version}) | |
`; | |
if (!writeFiles) { | |
console.log('# ' + filename); | |
console.log('======='.repeat(7)); | |
console.log(body); | |
} else { | |
fs.writeFileSync(filename, body); | |
} | |
} | |
function saveAuthors (userCache, writeFiles) { | |
const filename = OUT_AUTHORS_FILE; | |
let content = ''; | |
for (const [,author] of userCache) { | |
content += `${author.username}: ${author.name}\n`; | |
} | |
if (!writeFiles) { | |
console.log('# ' + filename); | |
console.log('======='.repeat(7)); | |
console.log(content); | |
} else { | |
fs.writeFileSync(filename, content); | |
} | |
} | |
async function ghApi (url) { | |
const token = process.env.GITHUB_API_TOKEN; | |
if (!token) throw new Error('Env GITHUB_API_TOKEN must be set'); | |
const resp = await fetch(url, { | |
headers: { | |
Accept: 'application/vnd.github+json', | |
Authorization: `Bearer ${token}`, | |
'X-GitHub-Api-Version': '2022-11-28' | |
} | |
}); | |
const data = await resp.json(); | |
if (!resp.ok) { | |
console.error(data); | |
throw new Error(`HTTP ${resp.status} from ${url}`); | |
} | |
return data; | |
} | |
function gitTagInfo (version) { | |
const lines = cp.execFileSync('git', [ | |
'show', | |
version + '^{commit}', // omit optional signed tag object | |
'--format=%aN\n%aE', | |
'-s' // omit diff | |
], { encoding: 'utf8' }).toString().trim().split('\n'); | |
return { | |
name: lines[0], | |
email: lines[1] | |
}; | |
} | |
await main(true); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment