2024-07-28 23:03:36 +10:00

402 lines
12 KiB
TypeScript

import ChangelogData from "./changelogData.ts";
import { categories, defaultIndentation } from "./definitions.ts";
import { Category, ChangelogMessage, Commit } from "#types/changelogTypes.ts";
import { repoLink } from "./definitions.ts";
import { Octokit } from "@octokit/rest";
import {
formatAuthor,
getIssueURL,
getNewestCommitAuthors,
getNewestIssueURLs,
} from "#utils/util.ts";
let data: ChangelogData;
let octokit: Octokit;
// How many lines the changelog (excluding the commit log) can be before the commit log is excluded.
const sectionLinesBeforeCommitLogExcluded = 50;
// How many lines the commit log can be before its is excluded.
const logLinesBeforeCommitLogExcluded = 20;
// How many commits to include after a message.
const maxIncludeCommits = 3;
export default async function pushAll(inputData: ChangelogData): Promise<void> {
pushTitle(inputData);
await pushChangelog(inputData);
}
export async function pushSetup(): Promise<void> {
octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
// Save Issue/PR Info to Cache
await getNewestIssueURLs(octokit);
}
export function pushTitle(inputData: ChangelogData): void {
data = inputData;
// Push the titles.
// Center Align is replaced by the correct center align style in the respective deployments.
// Must be triple bracketed, to make mustache not html escape it.
if (data.releaseType === "Cutting Edge Build") {
const date = new Date().toLocaleDateString("en-us", {
year: "numeric",
month: "short",
day: "numeric",
hour12: true,
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
});
// noinspection HtmlDeprecatedAttribute
data.builder.push(
`<h1 align="center">${data.releaseType} (${date})</h1>`,
"",
);
} else {
// noinspection HtmlUnknownAttribute
data.builder.push(
`<h1 {{{ CENTER_ALIGN }}}>${data.releaseType} ${data.to}</h1>`,
"",
);
data.builder.push("{{{ CF_REDIRECT }}}", "");
}
}
export async function pushChangelog(inputData: ChangelogData): Promise<void> {
data = inputData;
data.builder.push(`# Changes Since ${data.since}`, "");
// Push Sections of Changelog
for (const category of categories) {
await pushCategory(category);
}
// Push the commit log
if (data.commitList.length > 0) {
if (
data.builder.length < sectionLinesBeforeCommitLogExcluded &&
data.commitList.length < logLinesBeforeCommitLogExcluded
) {
// Commit List is relatively short, and most commits would have been handled via category pushing anyway.
// Just retrieve each author info sequentially.
sortCommitList(data.commitList, (commit) => commit);
data.builder.push("## Commits");
for (const commit of data.commitList) {
data.builder.push(await formatCommit(commit));
}
}
} else {
// No Commit List = No Changes
data.builder.push("");
data.builder.push("**There haven't been any changes.**");
}
// Push link
data.builder.push(
"",
`**Full Changelog**: [\`${data.since}...${data.to}\`](${repoLink}compare/${data.since}...${data.to})`,
);
}
export function pushSeperator(inputData: ChangelogData): void {
data = inputData;
data.builder.push("", "<hr>", "");
}
/**
* Pushes a given category to the builders.
*/
async function pushCategory(category: Category) {
const categoryLog: string[] = [];
let hasValues = false;
// Push All Sub Categories
for (const subCategory of category.subCategories) {
// Loop through key list instead of map to produce correct order
const list = category.changelogSection?.get(subCategory);
if (list && list.length != 0) {
// Push Key Name (only pushes if Key Name is not "")
if (subCategory.keyName) {
categoryLog.push(`### ${subCategory.keyName}:`);
}
// Format Main Messages (Async so Author Fetch is Fast)
await getNewestCommitAuthors(octokit);
const formatted: { message: ChangelogMessage; formatted: string }[] =
await Promise.all(
list.map((message) =>
formatChangelogMessage(message).then((formatted) => {
return { message, formatted };
}),
),
);
// Sort Log
sortCommitList(
formatted,
(formatted) => formatted.message.commitObject,
(a, b) =>
a.message.commitMessage.localeCompare(b.message.commitMessage),
);
// Push Log
for (const format of formatted) {
categoryLog.push(format.formatted);
// Push Sub Messages (No need for Async, Author Info Not Calculated in Sub Messages)
if (format.message.subChangelogMessages) {
for (const subMessage of format.message.subChangelogMessages)
categoryLog.push(await formatChangelogMessage(subMessage, true));
}
}
categoryLog.push("");
hasValues = true;
}
}
await transformAllIssueURLs(categoryLog);
if (hasValues) {
// Push Title
data.builder.push(`## ${category.categoryName}:`);
// Push previously made log
data.builder.push(...categoryLog);
}
}
/**
* Sorts a list that contains commit data
* @param list A list of type T that contains commit data
* @param transform A function to turn each element of type T into an element of type Commit
* @param backup A backup sort, to call when either element does not have a commit object, or when the commit objects' times are the same. Optional, if not set, will just return 0 (equal) or will compare commit messages.
*/
function sortCommitList<T>(
list: T[],
transform: (obj: T) => Commit | undefined,
backup?: (a: T, b: T) => number,
) {
list.sort((a, b): number => {
const commitA = transform(a);
const commitB = transform(b);
if (!commitA || !commitB) {
// If either commit is undefined
if (backup) return backup(a, b);
return 0;
}
const dateA = new Date(commitA.date);
const dateB = new Date(commitB.date);
// This is reversed, so higher priorities go on top
if (commitB.priority !== commitA.priority)
return (commitB.priority ?? 0) - (commitA.priority ?? 0);
// This is reversed, so the newest commits go on top
if (dateB.getTime() - dateA.getTime() !== 0)
return dateB.getTime() - dateA.getTime();
if (backup) return backup(a, b);
return commitA.message.localeCompare(commitB.message);
});
}
/**
* Sorts a commits list so that newest commits are on the bottom.
* @param list The commit list.
*/
export function sortCommitListReverse(list: Commit[]): void {
list.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
// This is reversed, so higher priorities go on top
if (b.priority !== a.priority) return (b.priority ?? 0) - (a.priority ?? 0); // Priority is still highest first
if (dateA.getTime() - dateB.getTime() !== 0)
return dateA.getTime() - dateB.getTime();
return a.message.localeCompare(b.message);
});
}
/**
* Formats a Changelog Message
* @param changelogMessage The message to format.
* @param subMessage Whether this message is a subMessage (used in details). Set to true to make it a subMessage (different parsing). Defaults to false.
* @return string Formatted Changelog Message
*/
async function formatChangelogMessage(
changelogMessage: ChangelogMessage,
subMessage = false,
): Promise<string> {
const indentation =
changelogMessage.indentation == undefined
? defaultIndentation
: changelogMessage.indentation;
const message = changelogMessage.commitMessage.trim();
if (changelogMessage.specialFormatting)
return changelogMessage.specialFormatting.formatting(
message,
subMessage,
indentation,
changelogMessage.specialFormatting.storage,
);
if (!changelogMessage.commitObject || subMessage) {
return formatMessage(message, indentation, undefined, subMessage);
}
if (data.combineList.has(changelogMessage.commitObject.hash)) {
const commits =
data.combineList.get(changelogMessage.commitObject.hash) ?? [];
commits.push(changelogMessage.commitObject);
return formatMessage(message, indentation, commits, subMessage);
}
return formatMessage(
message,
indentation,
[changelogMessage.commitObject],
subMessage,
);
}
/**
* Formats a Changelog Message
* @param message The message to format.
* @param indentation Indentation to use.
* @param commits List of Commits
* @param subMessage Whether this message is a subMessage (used in details). Set to true to make it a subMessage (different parsing). Defaults to false.
* @return string Formatted Changelog Message
*/
export async function formatMessage(
message: string,
indentation: string,
commits?: Commit[],
subMessage = false,
): Promise<string> {
if (!commits || commits.length == 0 || subMessage) {
return `${indentation}* ${message}`;
}
if (commits.length === 1) {
const commit = commits[0];
const shortSHA = commit.hash.substring(0, 7);
const formattedCommit = `[\`${shortSHA}\`](${repoLink}commit/${commit.hash})`;
const author = await formatAuthor(commit, octokit);
return `${indentation}* ${message} - ${author} (${formattedCommit})`;
}
// Sort original array so newest commits appear at the end instead of start of commit string
sortCommitListReverse(commits);
const formattedCommits: string[] = [];
const authors: string[] = [];
const retrievedAuthors: { commit: Commit; formatted: string }[] =
await Promise.all(
commits.map((commit) =>
formatAuthor(commit, octokit).then((formatted) => {
return { commit, formatted };
}),
),
);
const processedAuthors: Set<string> = new Set<string>();
const processedEmails: Set<string> = new Set<string>();
const processedSHAs: Set<string> = new Set<string>();
sortCommitList(
retrievedAuthors,
(author) => author.commit,
(a, b) => a.formatted.localeCompare(b.formatted),
);
retrievedAuthors.forEach((pAuthor) => {
if (processedSHAs.has(pAuthor.commit.hash)) return;
if (
!processedAuthors.has(pAuthor.formatted) &&
!processedEmails.has(pAuthor.commit.author_email)
) {
authors.push(pAuthor.formatted);
processedAuthors.add(pAuthor.formatted);
processedEmails.add(pAuthor.commit.author_email);
}
formattedCommits.push(
`[\`${pAuthor.commit.hash.substring(0, 7)}\`](${repoLink}commit/${pAuthor.commit.hash})`,
);
processedSHAs.add(pAuthor.commit.hash);
});
// Delete all Formatted Commits after MaxIncludeCommits elements, replace with '...'
if (formattedCommits.length > maxIncludeCommits) {
formattedCommits.splice(maxIncludeCommits, Infinity, "...");
}
return `${indentation}* ${message} - ${authors.join(", ")} (${formattedCommits.join(", ")})`;
}
/**
* Returns a formatted commit
*/
async function formatCommit(commit: Commit): Promise<string> {
const date = new Date(commit.date).toLocaleDateString("en-us", {
year: "numeric",
month: "short",
day: "numeric",
});
const formattedCommit = `${commit.message} - ${await formatAuthor(commit, octokit)} (${date})`;
const shortSHA = commit.hash.substring(0, 7);
return `* [\`${shortSHA}\`](${repoLink}commit/${commit.hash}): ${formattedCommit}`;
}
/**
* Transforms PR/Issue Tags in all strings of the generated changelog.
* @param changelog The list to transform all PR/Issue Tags of.
*/
async function transformAllIssueURLs(changelog: string[]) {
const promises: Promise<string>[] = [];
for (let i = 0; i < changelog.length; i++) {
const categoryFormatted = changelog[i];
// Transform PR and/or Issue tags into a link.
promises.push(
transformTags(categoryFormatted).then(
(categoryTransformed) => (changelog[i] = categoryTransformed),
),
);
}
// Apply all Link Changes
await Promise.all(promises);
}
/**
* Transforms PR/Issue Tags into Links.
*/
async function transformTags(message: string): Promise<string> {
const promises: Promise<string>[] = [];
if (message.search(/#\d+/) !== -1) {
const matched = message.match(/#\d+/g) ?? [];
for (const match of matched) {
// Extract digits
const digitsMatch = match.match(/\d+/);
if (!digitsMatch) continue;
const digits = Number.parseInt(digitsMatch[0]);
// Get PR/Issue Info (PRs are listed in the Issue API Endpoint)
promises.push(
getIssueURL(digits, octokit).then((url) =>
message.replace(match, `[#${digits}](${url})`),
),
);
}
}
// Resolve all Issue URL Replacements
await Promise.all(promises);
return message;
}