import { git, readPackageJSON, readPackageJSONAtRef } from './utils.ts';
const packageJSON = readPackageJSON();
const labelsConfig: { [label: string]: { section: string; fold?: boolean } } = {
'PR: breaking change 💥': {
section: 'Breaking Change 💥',
},
'PR: deprecation ⚠': {
section: 'Deprecation ⚠',
},
'PR: feature 🚀': {
section: 'New Feature 🚀',
},
'PR: bug fix 🐞': {
section: 'Bug Fix 🐞',
},
'PR: docs 📝': {
section: 'Docs 📝',
fold: true,
},
'PR: polish 💅': {
section: 'Polish 💅',
fold: true,
},
'PR: internal 🏠': {
section: 'Internal 🏠',
fold: true,
},
'PR: dependency 📦': {
section: 'Dependency 📦',
fold: true,
},
};
const { GH_TOKEN } = process.env;
if (GH_TOKEN == null) {
console.error('Must provide GH_TOKEN as environment variable!');
process.exit(1);
}
if (!packageJSON.repository || typeof packageJSON.repository.url !== 'string') {
console.error('package.json is missing repository.url string!');
process.exit(1);
}
const repoURLMatch =
/https:\/\/github.com\/(?<githubOrg>[^/]+)\/(?<githubRepo>[^/]+).git/.exec(
packageJSON.repository.url,
);
if (repoURLMatch?.groups == null) {
console.error('Cannot extract organization and repo name from repo URL!');
process.exit(1);
}
const { githubOrg, githubRepo } = repoURLMatch.groups;
process.stdout.write(await genChangeLog());
function parseFromRevArg(rawArgs: ReadonlyArray<string>): string | null {
if (rawArgs.length === 0) {
return null;
}
if (rawArgs.length === 1 && rawArgs[0].trim() !== '') {
return rawArgs[0];
}
throw new Error(
'Usage: npm run changelog [-- <fromRev>]\n' +
'Example: npm run changelog -- d41f59bbfdfc207712a2fc3778934694a3410ddf',
);
}
function getTaggedVersionCommit(version: string): string | null {
const tag = `v${version}`;
if (!git().tagExists(tag)) {
return null;
}
return git({ quiet: true }).revParse(`${tag}^{}`);
}
function getFirstParentCommit(commit: string): string | null {
const [commitWithParents] = git().revList('--parents', '-n', '1', commit);
if (commitWithParents == null) {
return null;
}
const [, firstParent] = commitWithParents.split(' ');
return firstParent ?? null;
}
function resolveCommitRefOrThrow(ref: string): string {
try {
return git().revParse(ref);
} catch (error) {
throw new Error(
`Unable to resolve fromRev "${ref}" to a local commit. ` +
'Pass a reachable first-parent revision:\n' +
' npm run changelog -- <fromRev>',
{ cause: error },
);
}
}
function resolveChangeLogConfig(
workingTreeVersion: string,
fromRev: string | null,
): {
title: string;
commitsList: Array<string>;
} {
const workingTreeReleaseTag = `v${workingTreeVersion}`;
const title = git().tagExists(workingTreeReleaseTag)
? 'Unreleased'
: workingTreeReleaseTag;
const commitsList: Array<string> = [];
let rangeStart =
fromRev != null
? resolveCommitRefOrThrow(fromRev)
: getTaggedVersionCommit(workingTreeVersion);
let rangeStartReached = false;
let lastCheckedVersion = workingTreeVersion;
let newerCommit: string | null = null;
let newerVersion: string | null = null;
let commit: string | null = git().revParse('HEAD');
while (commit != null) {
const commitVersion = readPackageJSONAtRef(commit).version;
if (rangeStart == null && commitVersion !== lastCheckedVersion) {
rangeStart = getTaggedVersionCommit(commitVersion);
lastCheckedVersion = commitVersion;
}
if (newerCommit != null && newerVersion === commitVersion) {
commitsList.push(newerCommit);
}
if (rangeStart != null && commit === rangeStart) {
rangeStartReached = true;
break;
}
newerCommit = commit;
newerVersion = commitVersion;
commit = getFirstParentCommit(commit);
}
if (rangeStart == null || !rangeStartReached) {
throw new Error(
'Unable to determine changelog range from local first-parent history.\n' +
'This can happen with a shallow clone, missing tags, or an unreachable fromRev.\n' +
'Fetch more history/tags (for example, "git fetch --tags --deepen=200") ' +
'or pass an explicit reachable first-parent fromRev:\n' +
' npm run changelog -- <fromRev>',
);
}
return {
title,
commitsList: commitsList.reverse(),
};
}
async function genChangeLog(): Promise<string> {
const workingTreeVersion = packageJSON.version;
const fromRev = parseFromRevArg(process.argv.slice(2));
const { title, commitsList } = resolveChangeLogConfig(
workingTreeVersion,
fromRev,
);
const allPRs = await getPRsInfo(commitsList);
const date = git().log('-1', '--format=%cd', '--date=short');
const byLabel: { [label: string]: Array<PRInfo> } = {};
const committersByLogin: { [login: string]: AuthorInfo } = {};
const validationIssues: Array<string> = [];
for (const pr of allPRs) {
const labels = pr.labels.nodes
.map((label) => label.name)
.filter((label) => label.startsWith('PR: '));
if (labels.length === 0) {
validationIssues.push(`PR #${pr.number} is missing label. See ${pr.url}`);
continue;
}
if (labels.length > 1) {
validationIssues.push(
`PR #${pr.number} has conflicting labels: ${labels.join(', ')}\nSee ${pr.url}`,
);
continue;
}
const label = labels[0];
if (labelsConfig[label] == null) {
validationIssues.push(
`PR #${pr.number} has unknown label: ${label}\nSee ${pr.url}`,
);
continue;
}
byLabel[label] ??= [];
byLabel[label].push(pr);
committersByLogin[pr.author.login] = pr.author;
}
if (validationIssues.length > 0) {
throw new Error(validationIssues.join('\n\n'));
}
let changelog = `## ${title} (${date})\n`;
for (const [label, config] of Object.entries(labelsConfig)) {
const prs = byLabel[label];
if (prs != null) {
const shouldFold = config.fold && prs.length > 1;
changelog += `\n#### ${config.section}\n`;
if (shouldFold) {
changelog += '<details>\n';
changelog += `<summary> ${prs.length} PRs were merged </summary>\n\n`;
}
for (const pr of prs) {
const { number, url, author } = pr;
changelog += `* [#${number}](${url}) ${pr.title} ([@${author.login}](${author.url}))\n`;
}
if (shouldFold) {
changelog += '</details>\n';
}
}
}
const committers = Object.values(committersByLogin).sort((a, b) =>
(a.name || a.login).localeCompare(b.name || b.login),
);
changelog += `\n#### Committers: ${committers.length}\n`;
for (const committer of committers) {
changelog += `* ${committer.name}([@${committer.login}](${committer.url}))\n`;
}
return changelog;
}
async function graphqlRequest(query: string) {
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: 'bearer ' + GH_TOKEN,
'Content-Type': 'application/json',
'User-Agent': 'gen-changelog',
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
throw new Error(
`GitHub responded with ${response.status}: ${response.statusText}\n` +
(await response.text()),
);
}
const json = (await response.json()) as {
data: unknown;
errors?: ReadonlyArray<unknown>;
};
if (json.errors != null) {
throw new Error('Errors: ' + JSON.stringify(json.errors, null, 2));
}
return json.data;
}
interface RepositoryCommitInfo {
repository: {
[commit: string]: CommitInfo;
};
}
interface CommitInfo {
oid: string;
message: string;
associatedPullRequests: {
nodes: ReadonlyArray<{
number: number;
merged: boolean;
repository: {
nameWithOwner: string;
};
}>;
};
}
async function batchCommitToPR(
commits: ReadonlyArray<string>,
): Promise<ReadonlyArray<number>> {
let commitsSubQuery = '';
for (const oid of commits) {
commitsSubQuery += `
commit_${oid}: object(oid: "${oid}") {
... on Commit {
oid
message
associatedPullRequests(first: 10) {
nodes {
number
merged
repository {
nameWithOwner
}
}
}
}
}
`;
}
const response = (await graphqlRequest(`
{
repository(owner: "${githubOrg}", name: "${githubRepo}") {
${commitsSubQuery}
}
}
`)) as RepositoryCommitInfo;
const prNumbers = [];
for (const oid of commits) {
const commitInfo: CommitInfo = response.repository['commit_' + oid];
prNumbers.push(commitInfoToPR(commitInfo));
}
return prNumbers;
}
interface AuthorInfo {
login: string;
url: string;
name: string;
}
interface RepositoryPrInfo {
repository: {
[pr: string]: PRInfo;
};
}
interface PRInfo {
number: number;
title: string;
url: string;
author: AuthorInfo;
labels: {
nodes: ReadonlyArray<{
name: string;
}>;
};
}
async function batchPRInfo(
prNumbers: ReadonlyArray<number>,
): Promise<Array<PRInfo>> {
let prsSubQuery = '';
for (const number of prNumbers) {
prsSubQuery += `
pr_${number}: pullRequest(number: ${number}) {
number
title
url
author {
login
url
... on User {
name
}
}
labels(first: 10) {
nodes {
name
}
}
}
`;
}
const response = (await graphqlRequest(`
{
repository(owner: "${githubOrg}", name: "${githubRepo}") {
${prsSubQuery}
}
}
`)) as RepositoryPrInfo;
const prsInfo = [];
for (const number of prNumbers) {
prsInfo.push(response.repository['pr_' + number]);
}
return prsInfo;
}
function commitInfoToPR(commit: CommitInfo): number {
const associatedPRs = commit.associatedPullRequests.nodes.filter(
(pr) =>
pr.merged && pr.repository.nameWithOwner === `${githubOrg}/${githubRepo}`,
);
if (associatedPRs.length === 0) {
const match = / \(#(?<prNumber>[0-9]+)\)$/m.exec(commit.message);
if (match?.groups?.prNumber != null) {
return parseInt(match.groups.prNumber, 10);
}
throw new Error(
`Commit ${commit.oid} has no associated PR: ${commit.message}`,
);
}
if (associatedPRs.length > 1) {
throw new Error(
`Commit ${commit.oid} is associated with multiple PRs: ${commit.message}`,
);
}
return associatedPRs[0].number;
}
async function getPRsInfo(
commits: ReadonlyArray<string>,
): Promise<ReadonlyArray<PRInfo>> {
let prNumbers = await splitBatches(commits, batchCommitToPR);
prNumbers = Array.from(new Set(prNumbers));
return splitBatches(prNumbers, batchPRInfo);
}
async function splitBatches<I, R>(
array: ReadonlyArray<I>,
batchFn: (array: ReadonlyArray<I>) => Promise<ReadonlyArray<R>>,
): Promise<ReadonlyArray<R>> {
const promises = [];
for (let i = 0; i < array.length; i += 50) {
const batchItems = array.slice(i, i + 50);
promises.push(batchFn(batchItems));
}
return (await Promise.all(promises)).flat();
}