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, getCommitAuthors, getIssueURLs, } from "#utils/util.ts"; import logInfo from "#utils/log.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 { pushTitle(inputData); await pushChangelog(inputData); } export async function pushSetup(): Promise { octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, }); // Fill Caches logInfo("Filling Caches..."); await getIssueURLs(octokit); await getCommitAuthors(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( `

${data.releaseType} (${date})

`, "", ); } else { // noinspection HtmlUnknownAttribute data.builder.push( `

${data.releaseType} ${data.to}

`, "", ); data.builder.push("{{{ CF_REDIRECT }}}", ""); } } export async function pushChangelog(inputData: ChangelogData): Promise { 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("", "
", ""); } /** * 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) 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( 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 { 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 { 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 = new Set(); const processedEmails: Set = new Set(); const processedSHAs: Set = new Set(); 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 { 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[] = []; 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 { const promises: Promise[] = []; 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; }