Skip to content

Instantly share code, notes, and snippets.

@Krinkle
Created January 16, 2025 20:54
Show Gist options
  • Save Krinkle/684c766ff33346946b6ce144b1751b64 to your computer and use it in GitHub Desktop.
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/
/* 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