diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f5a391..89ad41e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ -name: 'CI' +name: "CI" on: push: tags: - - '*' + - "*" pull_request: concurrency: @@ -25,13 +25,13 @@ jobs: - name: Install NPM run: | npm install - + - name: Run NPM run: | npm run all env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + test: if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -72,7 +72,7 @@ jobs: } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + - name: Echo Configuration without Checkout Changelog env: CHANGELOG: ${{ steps.without_checkout.outputs.changelog }} @@ -89,7 +89,7 @@ jobs: uses: ./ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + - name: Echo Minimal Configuration Changelog env: CHANGELOG: ${{ steps.minimal_release.outputs.changelog }} @@ -125,7 +125,7 @@ jobs: token: ${{ secrets.PERSONAL_TOKEN }} exportCache: true exportOnly: true - + - name: "External Repo Configuration" id: external_changelog uses: ./ @@ -188,4 +188,3 @@ jobs: prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-b') || contains(github.ref, '-a') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.prettierrc.json b/.prettierrc.json index 0667e0d..939a8bd 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -6,5 +6,13 @@ "singleQuote": true, "trailingComma": "none", "bracketSpacing": false, - "arrowParens": "avoid" + "arrowParens": "avoid", + "overrides": [ + { + "files": ["*.yml", "*.yaml"], + "options": { + "singleQuote": false + } + } + ] } diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 99d6577..7766dbd 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -19,7 +19,7 @@ test('missing values should result in failure', () => { } try { cp.execSync(`node ${ip}`, options).toString() - } catch (error: any) { + } catch (error: unknown) { expect(true).toBe(true) } }) diff --git a/__tests__/releaseNotesBuilder.test.ts b/__tests__/releaseNotesBuilder.test.ts index 952f172..330c804 100644 --- a/__tests__/releaseNotesBuilder.test.ts +++ b/__tests__/releaseNotesBuilder.test.ts @@ -226,7 +226,7 @@ it('[Github] Uncategorized category', async () => { }) it('[Github] Verify commit based changelog', async () => { - const configuration = mergeConfiguration(undefined, resolveConfiguration('', 'configs_test/configuration_commits.json')) + const configuration = mergeConfiguration(undefined, resolveConfiguration('', 'configs_test/configuration_commits.json'), 'COMMIT') const releaseNotesBuilder = new ReleaseNotesBuilder( null, githubRepository, @@ -257,7 +257,7 @@ it('[Github] Verify commit based changelog', async () => { }) it('[Github] Verify commit based changelog, with emoji categorisation', async () => { - const configuration = mergeConfiguration(undefined, resolveConfiguration('', 'configs_test/configuration_commits_emoji.json')) + const configuration = mergeConfiguration(undefined, resolveConfiguration('', 'configs_test/configuration_commits_emoji.json'), 'COMMIT') const releaseNotesBuilder = new ReleaseNotesBuilder( null, githubRepository, diff --git a/__tests__/transform.test.ts b/__tests__/transform.test.ts index 9407846..3d336c9 100644 --- a/__tests__/transform.test.ts +++ b/__tests__/transform.test.ts @@ -193,6 +193,7 @@ it('Extract label from title, split regex', async () => { on_property: 'title' } ] + expect(buildChangelogTest(configuration, mergedPullRequests, repositoryUtils)).toStrictEqual( `## 🚀 Features\n\n- [Feature][AB-1234] - this is a PR 1 title message\n - PR: #1\n- [Issue][Feature][AB-1234321] - this is a PR 3 title message\n - PR: #3\n\n## 🐛 Fixes\n\n- [Issue][AB-4321] - this is a PR 2 title message\n - PR: #2\n- [Issue][Feature][AB-1234321] - this is a PR 3 title message\n - PR: #3\n\n` ) @@ -448,7 +449,8 @@ it('Use empty_content for empty category', async () => { labels: ['Feature'] } ] - expect(buildChangelogTest(customConfig, pullRequestsWithLabels, repositoryUtils)).toStrictEqual( + const changelog = buildChangelogTest(customConfig, pullRequestsWithLabels, repositoryUtils) + expect(changelog).toStrictEqual( `## 🚀 Features and 🐛 Issues\n\n- No PRs in this category\n\n## 🚀 Features\n\n- [ABC-1234] - this is a PR 1 title message\n - PR: #1\n- [ABC-1234] - this is a PR 3 title message\n - PR: #3\n\n` ) }) diff --git a/src/configuration.ts b/src/configuration.ts index ff2e1c6..ce758d0 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,11 +1,6 @@ -import {Extractor, PullConfiguration, Regex, Rule, Sort} from './pr-collector/types' +import {Extractor, PullConfiguration, Regex, Rule} from './pr-collector/types' export interface Configuration extends PullConfiguration { - max_tags_to_fetch: number - max_pull_requests: number - max_back_track_time_days: number - exclude_merge_branches: string[] - sort: Sort | string // "ASC" or "DESC" template: string pr_template: string empty_template: string @@ -15,8 +10,6 @@ export interface Configuration extends PullConfiguration { duplicate_filter?: Extractor // extract an identifier from a PR used to detect duplicates, will keep the last match (depends on `sort`) reference?: Extractor // extracts a reference from a PR, used to establish parent child relations. This will remove the child from the main PR list. transformers: Regex[] - tag_resolver: TagResolver - base_branches: string[] custom_placeholders?: Placeholder[] trim_values: boolean } @@ -63,6 +56,8 @@ export interface Placeholder { transformer: Regex // the transformer to use to transform the original placeholder into the custom placeheolder } +export class PlaceholderGroup extends Map {} + export const DefaultConfiguration: Configuration = { max_tags_to_fetch: 200, // the amount of tags to fetch from the github API max_pull_requests: 200, // the amount of pull requests to process @@ -110,14 +105,8 @@ export const DefaultConfiguration: Configuration = { } export const DefaultCommitConfiguration: Configuration = { - max_tags_to_fetch: DefaultConfiguration.max_tags_to_fetch, - max_pull_requests: DefaultConfiguration.max_pull_requests, - max_back_track_time_days: DefaultConfiguration.max_back_track_time_days, - exclude_merge_branches: DefaultConfiguration.exclude_merge_branches, - sort: DefaultConfiguration.sort, - template: '#{{CHANGELOG}}', // the global template to host the changelog - pr_template: '- #{{TITLE}}', // the per PR template to pick for commit based mode - empty_template: DefaultConfiguration.empty_template, + ...DefaultConfiguration, + pr_template: '- #{{TITLE}}', // the per PR template to pick categories: [ { title: '## 🚀 Features', @@ -135,17 +124,11 @@ export const DefaultCommitConfiguration: Configuration = { title: '## đŸ“Ļ Other', labels: [] } - ], // the categories to support for the ordering - ignore_labels: DefaultConfiguration.ignore_labels, + ], label_extractor: [ { pattern: '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)', target: '$1' } - ], - transformers: DefaultConfiguration.transformers, - tag_resolver: DefaultConfiguration.tag_resolver, - base_branches: DefaultConfiguration.base_branches, - custom_placeholders: DefaultConfiguration.custom_placeholders, - trim_values: DefaultConfiguration.trim_values + ] } diff --git a/src/pr-collector/pullRequests.ts b/src/pr-collector/pullRequests.ts index 938d41e..8555692 100644 --- a/src/pr-collector/pullRequests.ts +++ b/src/pr-collector/pullRequests.ts @@ -28,6 +28,10 @@ export interface PullRequestInfo { status: 'open' | 'merged' } +export interface PullRequestData extends PullRequestInfo { + childPrs?: PullRequestInfo[] +} + export interface CommentInfo { id: number htmlURL: string @@ -79,7 +83,7 @@ export class PullRequests { ) {} async getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise { - return sortPrs(await this.repositoryUtils.getForCommitHash(owner, repo, commit_sha, maxPullRequests)) + return sortPrsByMergedAt(await this.repositoryUtils.getForCommitHash(owner, repo, commit_sha, maxPullRequests)) } async getBetweenDates( @@ -89,11 +93,11 @@ export class PullRequests { toDate: moment.Moment, maxPullRequests: number ): Promise { - return sortPrs(await this.repositoryUtils.getBetweenDates(owner, repo, fromDate, toDate, maxPullRequests)) + return sortPrsByMergedAt(await this.repositoryUtils.getBetweenDates(owner, repo, fromDate, toDate, maxPullRequests)) } async getOpen(owner: string, repo: string, maxPullRequests: number): Promise { - return sortPrs(await this.repositoryUtils.getOpen(owner, repo, maxPullRequests)) + return sortPrsByMergedAt(await this.repositoryUtils.getOpen(owner, repo, maxPullRequests)) } async getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise { @@ -221,7 +225,7 @@ export class PullRequests { } } -function sortPrs(pullRequests: PullRequestInfo[]): PullRequestInfo[] { +function sortPrsByMergedAt(pullRequests: PullRequestInfo[]): PullRequestInfo[] { return sortPullRequests(pullRequests, { order: 'ASC', on_property: 'mergedAt' diff --git a/src/pr-collector/types.ts b/src/pr-collector/types.ts index 9189dcf..43c623f 100644 --- a/src/pr-collector/types.ts +++ b/src/pr-collector/types.ts @@ -58,3 +58,20 @@ export interface RegexTransformer { method?: 'replace' | 'replaceAll' | 'match' | 'regexr' onEmpty?: string } + +export class GroupedTemplateContext extends Map {} +export class TemplateContext extends Map {} + +export type ChangelogStrings = { + categorized: string + uncategorized: string + open: string + ignored: string +} + +export type PrStrings = { + categorizedList: string[] + uncategorizedList: string[] + openList: string[] + ignoredList: string[] +} diff --git a/src/regexUtils.ts b/src/regexUtils.ts index 749cf6c..ce2b01e 100644 --- a/src/regexUtils.ts +++ b/src/regexUtils.ts @@ -6,8 +6,8 @@ import {validateRegex} from './pr-collector/regexUtils' /** * Checks if any of the rules match the given PR */ -export function matchesRules(rules: Rule[], pr: PullRequestInfo, exhaustive: Boolean): boolean { - const transformers: RegexTransformer[] = rules.map(rule => validateRegex(rule)).filter(t => t !== null) as RegexTransformer[] +export function matchesRules(rules: Rule[], pr: PullRequestInfo, exhaustive: boolean): boolean { + const transformers: RegexTransformer[] = rules.map(rule => validateRegex(rule)).filter(t => t !== null) if (exhaustive) { return transformers.every(transformer => { return matches(pr, transformer, 'rule') diff --git a/src/releaseNotesBuilder.ts b/src/releaseNotesBuilder.ts index 376b61a..949e3d2 100644 --- a/src/releaseNotesBuilder.ts +++ b/src/releaseNotesBuilder.ts @@ -1,12 +1,12 @@ import * as core from '@actions/core' import {Configuration} from './configuration' import {checkExportedData, writeCacheData} from './utils' -import {buildChangelog, PullRequestData} from './transform' +import {buildChangelog} from './transform' import {PullRequestCollector} from './pr-collector/prCollector' import {failOrError} from './pr-collector/utils' import {TagInfo} from './pr-collector/tags' import {DiffInfo} from './pr-collector/commits' -import {PullRequestInfo} from './pr-collector/pullRequests' +import {PullRequestData, PullRequestInfo} from './pr-collector/pullRequests' import {BaseRepository} from './repositories/BaseRepository' export interface ReleaseNotesOptions { diff --git a/src/transform.ts b/src/transform.ts index 547208a..f3404e3 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,38 +1,34 @@ import * as core from '@actions/core' -import {Category, Configuration, Placeholder, Property} from './configuration' -import {createOrSet, haveCommonElementsArr, haveEveryElementsArr} from './utils' +import {Category, Configuration, Placeholder, PlaceholderGroup, Property} from './configuration' +import {createOrSet, groupPlaceholders, haveCommonElementsArr, haveEveryElementsArr, mergeMaps} from './utils' import { CommentInfo, EMPTY_COMMENT_INFO, EMPTY_PULL_REQUEST_INFO, + PullRequestData, PullRequestInfo, retrieveProperty, sortPullRequests } from './pr-collector/pullRequests' import {DiffInfo} from './pr-collector/commits' import {transformStringToOptionalValue, transformStringToValues, validateRegex} from './pr-collector/regexUtils' -import {Regex, RegexTransformer} from './pr-collector/types' +import {ChangelogStrings, GroupedTemplateContext, PrStrings, Regex, RegexTransformer, TemplateContext} from './pr-collector/types' import {ReleaseNotesOptions} from './releaseNotesBuilder' import {matchesRules} from './regexUtils' -const EMPTY_MAP = new Map() let CLEAR = false export function clear(): void { CLEAR = true } -export interface PullRequestData extends PullRequestInfo { - childPrs?: PullRequestInfo[] -} - export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], options: ReleaseNotesOptions): string { core.startGroup('đŸ“Ļ Build changelog') let prs: PullRequestData[] = origPrs if (prs.length === 0) { core.warning(`âš ī¸ No pull requests found`) - const result = replaceEmptyTemplate(options.configuration.empty_template, options) + const result = renderEmptyChangelogTemplate(options.configuration.empty_template, options) core.endGroup() return result } @@ -123,31 +119,32 @@ export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], o } } - // keep reference for the placeholder values - const placeholders = new Map() - for (const ph of config.custom_placeholders || []) { - createOrSet(placeholders, ph.source, ph) - } - const placeholderPrMap = new Map() + const groupedPlaceholders = groupPlaceholders(config.custom_placeholders || []) + + const customPlaceholdersTemplateContext = new GroupedTemplateContext() const validatedTransformers = validateTransformers(config.transformers) - const transformedMap = new Map() - // convert PRs to their text representation - for (const pr of prs) { - transformedMap.set(pr, transform(fillPrTemplate(pr, config.pr_template, placeholders, placeholderPrMap, config), validatedTransformers)) - } + core.info(`â„šī¸ Used ${validatedTransformers.length} transformers to adjust message`) - core.info(`âœ’ī¸ Wrote messages for ${prs.length} pull requests`) + + for (const pr of prs) { + const prAsObject = pr as unknown as Record + transformObject(prAsObject, validatedTransformers) + } + + core.info(`âœ’ī¸ Wrote messages for ${prs.length} commits`) + + const prInfoMap = buildInfoMapAndFillPlaceholderContext( + prs, + config.pr_template, + groupedPlaceholders, + customPlaceholdersTemplateContext, + config + ) // bring PRs into the order of categories const categories = config.categories - const ignoredLabels = config.ignore_labels - const flatCategories = flatten(config.categories) - const categorizedPrs: string[] = [] - const ignoredPrs: string[] = [] - const openPrs: string[] = [] - const uncategorizedPrs: string[] = [] // set-up the category object for (const category of flatCategories) { @@ -156,8 +153,109 @@ export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], o } } + const prStrings = buildPrStringsAndFillCategoryEntries(prInfoMap, config.ignore_labels, categories, flatCategories) + core.info(`â„šī¸ Ordered all pull requests into ${categories.length} categories`) + + // serialize and provide the categorized content as json + const transformedCategorized = buildCategorizedOutput(flatCategories) + core.setOutput('categorized', JSON.stringify(transformedCategorized)) + + // construct final changelog + const changelogStrings = buildChangelogStrings(flatCategories, prStrings) + + core.info(`âœ’ī¸ Wrote ${changelogStrings.categorized.length} categorized pull requests down`) + core.info(`âœ’ī¸ Wrote ${changelogStrings.uncategorized.length} non categorized pull requests down`) + core.info(`âœ’ī¸ Wrote ${changelogStrings.open.length} open pull requests down`) + core.info(`âœ’ī¸ Wrote ${changelogStrings.ignored.length} ignored pull requests down`) + + core.setOutput('categorized_prs', changelogStrings.categorized.length) + core.setOutput('uncategorized_prs', changelogStrings.uncategorized.length) + core.setOutput('open_prs', changelogStrings.open.length) + core.setOutput('ignored_prs', changelogStrings.ignored.length) + + // collect all contributors + const contributorsSet: Set = new Set(prs.map(pr => `@${pr.author}`)) + const contributorsArray = Array.from(contributorsSet) + const contributorsString = contributorsArray.join(', ') + const externalContributorString = contributorsArray.filter(value => value !== options.owner).join(', ') + core.setOutput('contributors', JSON.stringify(contributorsSet)) + + const releaseNotesTemplateContext = buildReleaseNotesTemplateContext( + changelogStrings, + contributorsString, + externalContributorString, + prStrings, + diffInfo, + options + ) + + let renderedReleaseNotesTemplate = renderTemplateAndFillPlaceholderContext( + config.template, + releaseNotesTemplateContext, + groupedPlaceholders, + customPlaceholdersTemplateContext, + config + ) + + renderedReleaseNotesTemplate = renderTemplateWithContext(renderedReleaseNotesTemplate, customPlaceholdersTemplateContext, config) + renderedReleaseNotesTemplate = cleanupPrPlaceholders(renderedReleaseNotesTemplate, groupedPlaceholders) + renderedReleaseNotesTemplate = cleanupPlaceholders(renderedReleaseNotesTemplate) + + core.info(`â„šī¸ Filled template`) + core.endGroup() + + return renderedReleaseNotesTemplate +} + +function buildInfoMapAndFillPlaceholderContext( + prData: PullRequestData[], + template: string, + groupedPlaceholders: Map, + customPlaceholdersTemplateContext: GroupedTemplateContext, + config: Configuration +): Map { + const infoMap = new Map() + + for (const pr of prData) { + const [prTemplateContext, prArrayTemplateContext] = buildPrTemplateContext(pr) + + let renderedTemplate = template + + renderedTemplate = renderTemplateAndFillPlaceholderContext( + renderedTemplate, + prArrayTemplateContext, + groupedPlaceholders, + customPlaceholdersTemplateContext, + config + ) + + renderedTemplate = renderTemplateAndFillPlaceholderContext( + renderedTemplate, + prTemplateContext, + groupedPlaceholders, + customPlaceholdersTemplateContext, + config + ) + + infoMap.set(pr, renderedTemplate) + } + + return infoMap +} + +function buildPrStringsAndFillCategoryEntries( + prInfoMap: Map, + ignoredLabels: string[], + categories: Category[], + flatCategories: Category[] +): PrStrings { + const categorizedPrs: string[] = [] + const ignoredPrs: string[] = [] + const openPrs: string[] = [] + const uncategorizedPrs: string[] = [] + // bring elements in order - prLoop: for (const [pr, body] of transformedMap) { + prLoop: for (const [pr, body] of prInfoMap) { if ( haveCommonElementsArr( ignoredLabels.map(lbl => lbl.toLocaleLowerCase('en')), @@ -184,7 +282,7 @@ export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], o if (!matchedOnce) { // we allow to have pull requests included in an "uncategorized" category for (const category of flatCategories) { - const pullRequests = category.entries || [] + category.entries = category.entries || [] if ((category.labels === undefined || category.labels.length === 0) && category.rules === undefined) { // check if any exclude label matches for the "uncategorized" category if (category.exclude_labels !== undefined) { @@ -194,7 +292,7 @@ export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], o pr.labels ) ) { - pullRequests.push(body) + category.entries.push(body) } else if (core.isDebug()) { const excludeLabels = JSON.stringify(category.exclude_labels) core.debug( @@ -202,7 +300,7 @@ export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], o ) } } else { - pullRequests.push(body) + category.entries.push(body) } break @@ -215,106 +313,126 @@ export function buildChangelog(diffInfo: DiffInfo, origPrs: PullRequestInfo[], o categorizedPrs.push(body) } } - core.info(`â„šī¸ Ordered all pull requests into ${categories.length} categories`) - // serialize and provide the categorized content as json - const transformedCategorized = {} - for (const category of flatCategories) { - Object.assign(transformedCategorized, {[category.key || category.title]: category.entries}) + const prStrings: PrStrings = { + categorizedList: categorizedPrs, + uncategorizedList: uncategorizedPrs, + openList: openPrs, + ignoredList: ignoredPrs } - core.setOutput('categorized', JSON.stringify(transformedCategorized)) - // construct final changelog - let changelog = '' + return prStrings +} + +function buildChangelogStrings(flatCategories: Category[], prStrings: PrStrings): ChangelogStrings { + const {categorizedList, uncategorizedList, openList, ignoredList} = prStrings + + let changelogCategorized = '' for (const category of flatCategories) { const pullRequests = category.entries || [] - changelog = attachCategoryChangelog(changelog, category, pullRequests) + changelogCategorized += buildCategorizedChangelogString(category, pullRequests) } - core.info(`âœ’ī¸ Wrote ${categorizedPrs.length} categorized pull requests down`) if (core.isDebug()) { - for (const pr of categorizedPrs) { + for (const pr of categorizedList) { core.debug(` ${pr}`) } } - core.setOutput('categorized_prs', categorizedPrs.length) let changelogUncategorized = '' - for (const pr of uncategorizedPrs) { + for (const pr of uncategorizedList) { changelogUncategorized = `${changelogUncategorized + pr}\n` } - core.info(`âœ’ī¸ Wrote ${uncategorizedPrs.length} non categorized pull requests down`) if (core.isDebug()) { - for (const pr of uncategorizedPrs) { + for (const pr of uncategorizedList) { core.debug(` ${pr}`) } } - core.setOutput('uncategorized_prs', uncategorizedPrs.length) let changelogOpen = '' - if (openPrs.length > 0) { - for (const pr of openPrs) { + if (openList.length > 0) { + for (const pr of openList) { changelogOpen = `${changelogOpen + pr}\n` } - core.info(`âœ’ī¸ Wrote ${openPrs.length} open pull requests down`) if (core.isDebug()) { - for (const pr of openPrs) { + for (const pr of openList) { core.debug(` ${pr}`) } } - core.setOutput('open_prs', openPrs.length) } let changelogIgnored = '' - for (const pr of ignoredPrs) { + for (const pr of ignoredList) { changelogIgnored = `${changelogIgnored + pr}\n` } if (core.isDebug()) { - for (const pr of ignoredPrs) { + for (const pr of ignoredList) { core.debug(` ${pr}`) } } - core.info(`âœ’ī¸ Wrote ${ignoredPrs.length} ignored pull requests down`) - // collect all contributors - const contributorsSet: Set = new Set() - for (const pr of prs) { - contributorsSet.add(`@${pr.author}`) + const changelogStrings: ChangelogStrings = { + categorized: changelogCategorized, + uncategorized: changelogUncategorized, + open: changelogOpen, + ignored: changelogIgnored } - const contributorsArray = Array.from(contributorsSet) - const contributorsString = contributorsArray.join(', ') - const externalContributorString = contributorsArray.filter(value => value !== options.owner).join(', ') - core.setOutput('contributors', JSON.stringify(contributorsSet)) - // fill template - const placeholderMap = new Map() - placeholderMap.set('CHANGELOG', changelog) - placeholderMap.set('UNCATEGORIZED', changelogUncategorized) - placeholderMap.set('OPEN', changelogOpen) - placeholderMap.set('IGNORED', changelogIgnored) + return changelogStrings +} + +function buildReleaseNotesTemplateContext( + changelogStrings: ChangelogStrings, + contributorsString: string, + externalContributorString: string, + prStrings: PrStrings, + diffInfo: DiffInfo, + options: ReleaseNotesOptions +): TemplateContext { + const { + categorized: changelogCategorized, + uncategorized: changelogUncategorized, + open: changelogOpen, + ignored: changelogIgnored + } = changelogStrings + + const {categorizedList, uncategorizedList, openList, ignoredList} = prStrings + + let releaseNotesTemplateContext = new TemplateContext() + + releaseNotesTemplateContext.set('CHANGELOG', changelogCategorized) + releaseNotesTemplateContext.set('UNCATEGORIZED', changelogUncategorized) + releaseNotesTemplateContext.set('OPEN', changelogOpen) + releaseNotesTemplateContext.set('IGNORED', changelogIgnored) // fill special collected contributors - placeholderMap.set('CONTRIBUTORS', contributorsString) - placeholderMap.set('EXTERNAL_CONTRIBUTORS', externalContributorString) + releaseNotesTemplateContext.set('CONTRIBUTORS', contributorsString) + releaseNotesTemplateContext.set('EXTERNAL_CONTRIBUTORS', externalContributorString) // fill other placeholders - placeholderMap.set('CATEGORIZED_COUNT', categorizedPrs.length.toString()) - placeholderMap.set('UNCATEGORIZED_COUNT', uncategorizedPrs.length.toString()) - placeholderMap.set('OPEN_COUNT', openPrs.length.toString()) - placeholderMap.set('IGNORED_COUNT', ignoredPrs.length.toString()) + releaseNotesTemplateContext.set('CATEGORIZED_COUNT', categorizedList.length.toString()) + releaseNotesTemplateContext.set('UNCATEGORIZED_COUNT', uncategorizedList.length.toString()) + releaseNotesTemplateContext.set('OPEN_COUNT', openList.length.toString()) + releaseNotesTemplateContext.set('IGNORED_COUNT', ignoredList.length.toString()) // code change placeholders - placeholderMap.set('CHANGED_FILES', diffInfo.changedFiles.toString()) - placeholderMap.set('ADDITIONS', diffInfo.additions.toString()) - placeholderMap.set('DELETIONS', diffInfo.deletions.toString()) - placeholderMap.set('CHANGES', diffInfo.changes.toString()) - placeholderMap.set('COMMITS', diffInfo.commits.toString()) - fillAdditionalPlaceholders(options, placeholderMap) + releaseNotesTemplateContext.set('CHANGED_FILES', diffInfo.changedFiles.toString()) + releaseNotesTemplateContext.set('ADDITIONS', diffInfo.additions.toString()) + releaseNotesTemplateContext.set('DELETIONS', diffInfo.deletions.toString()) + releaseNotesTemplateContext.set('CHANGES', diffInfo.changes.toString()) + releaseNotesTemplateContext.set('COMMITS', diffInfo.commits.toString()) - let transformedChangelog = config.template - transformedChangelog = replacePlaceholders(transformedChangelog, EMPTY_MAP, placeholderMap, placeholders, placeholderPrMap, config) - transformedChangelog = replacePrPlaceholders(transformedChangelog, placeholderPrMap, config) - transformedChangelog = cleanupPrPlaceholders(transformedChangelog, placeholders) - transformedChangelog = cleanupPlaceholders(transformedChangelog) - core.info(`â„šī¸ Filled template`) - core.endGroup() - return transformedChangelog + const coreReleasesNotesContext = buildCoreReleaseNotesTemplateContext(options) + + releaseNotesTemplateContext = mergeMaps(releaseNotesTemplateContext, coreReleasesNotesContext) + + return releaseNotesTemplateContext +} + +function buildCategorizedOutput(flatCategories: Category[]): Record { + const transformedCategorized = {} + + for (const category of flatCategories) { + Object.assign(transformedCategorized, {[category.key || category.title]: category.entries}) + } + + return transformedCategorized } function recursiveCategorizePr(category: Category, pr: PullRequestInfo, body: string): boolean[] { @@ -334,10 +452,11 @@ function recursiveCategorizePr(category: Category, pr: PullRequestInfo, body: st // if consumed we don't handle it anymore, as it was matched in a child, don't handle anymore if (!consumed && !matched) { - const pullRequests = category.entries || [] + category.entries = category.entries || [] + matched = matchesParent if (matched) { - pullRequests.push(body) // if matched add the PR to the list + category.entries.push(body) // if matched add the PR to the list } } if (matched && category.consume) { @@ -401,156 +520,185 @@ function categorizePr(category: Category, pr: PullRequestInfo): boolean { return matched } -function attachCategoryChangelog(changelog: string, category: Category, pullRequests: string[]): string { +function buildCategorizedChangelogString(category: Category, pullRequests: string[]): string { + let categorizedString = '' + if (pullRequests.length > 0 || hasChildWithEntries(category)) { if (category.title) { - changelog = `${changelog + category.title}\n\n` + categorizedString = `${categorizedString + category.title}\n\n` } for (const pr of pullRequests) { - changelog = `${changelog + pr}\n` + categorizedString = `${categorizedString + pr}\n` } - changelog = `${changelog}\n` // add space between sections + categorizedString = `${categorizedString}\n` // add space between sections } else if (category.empty_content !== undefined) { if (category.title) { - changelog = `${changelog + category.title}\n\n` + categorizedString = `${categorizedString + category.title}\n\n` } - changelog = `${changelog + category.empty_content}\n\n` + categorizedString = `${categorizedString + category.empty_content}\n\n` } - return changelog + return categorizedString } -export function replaceEmptyTemplate(template: string, options: ReleaseNotesOptions): string { +export function renderEmptyChangelogTemplate(template: string, options: ReleaseNotesOptions): string { const placeholders = new Map() for (const ph of options.configuration.custom_placeholders || []) { createOrSet(placeholders, ph.source, ph) } - const placeholderMap = new Map() - fillAdditionalPlaceholders(options, placeholderMap) - return replacePlaceholders(template, EMPTY_MAP, placeholderMap, placeholders, undefined, options.configuration) + + const releaseNotesTemplateContext = buildCoreReleaseNotesTemplateContext(options) + + const renderedEmptyChangelogTemplate = renderTemplateAndFillPlaceholderContext( + template, + releaseNotesTemplateContext, + placeholders, + undefined, + options.configuration + ) + + return renderedEmptyChangelogTemplate } -function fillAdditionalPlaceholders( - options: ReleaseNotesOptions, - placeholderMap: Map /* placeholderKey and original value */ -): void { - placeholderMap.set('OWNER', options.owner) - placeholderMap.set('REPO', options.repo) - placeholderMap.set('FROM_TAG', options.fromTag.name) - placeholderMap.set('FROM_TAG_DATE', options.fromTag.date?.toISOString() || '') - placeholderMap.set('TO_TAG', options.toTag.name) - placeholderMap.set('TO_TAG_DATE', options.toTag.date?.toISOString() || '') +function buildCoreReleaseNotesTemplateContext(options: ReleaseNotesOptions): TemplateContext { + const templateContext = new TemplateContext() + + templateContext.set('OWNER', options.owner) + templateContext.set('REPO', options.repo) + templateContext.set('FROM_TAG', options.fromTag.name) + templateContext.set('FROM_TAG_DATE', options.fromTag.date?.toISOString() || '') + templateContext.set('TO_TAG', options.toTag.name) + templateContext.set('TO_TAG_DATE', options.toTag.date?.toISOString() || '') const fromDate = options.fromTag.date const toDate = options.toTag.date if (fromDate !== undefined && toDate !== undefined) { - placeholderMap.set('DAYS_SINCE', toDate.diff(fromDate, 'days').toString() || '') + templateContext.set('DAYS_SINCE', toDate.diff(fromDate, 'days').toString() || '') } else { - placeholderMap.set('DAYS_SINCE', '') + templateContext.set('DAYS_SINCE', '') } - placeholderMap.set( + templateContext.set( 'RELEASE_DIFF', `${options.repositoryUtils.homeUrl}/${options.owner}/${options.repo}/compare/${options.fromTag.name}...${options.toTag.name}` ) + + return templateContext } -function fillPrTemplate( - pr: PullRequestData, - template: string, - placeholders: Map /* placeholders to apply */, - placeholderPrMap: Map /* map to keep replaced placeholder values with their key */, - configuration: Configuration -): string { - const arrayPlaceholderMap = new Map() - fillReviewPlaceholders(arrayPlaceholderMap, 'REVIEWS', pr.reviews || []) - fillChildPrPlaceholders(arrayPlaceholderMap, 'REFERENCED', pr.childPrs || []) - const placeholderMap = new Map() - placeholderMap.set('NUMBER', pr.number.toString()) - placeholderMap.set('TITLE', pr.title) - placeholderMap.set('URL', pr.htmlURL) - placeholderMap.set('STATUS', pr.status) - placeholderMap.set('CREATED_AT', pr.createdAt.toISOString()) - placeholderMap.set('MERGED_AT', pr.mergedAt?.toISOString() || '') - placeholderMap.set('MERGE_SHA', pr.mergeCommitSha) - placeholderMap.set('AUTHOR', pr.author) - placeholderMap.set('AUTHOR_NAME', pr.authorName || '') - placeholderMap.set('LABELS', [...pr.labels]?.filter(l => !l.startsWith('--rcba-'))?.join(', ') || '') - placeholderMap.set('MILESTONE', pr.milestone || '') - placeholderMap.set('BODY', pr.body) - fillArrayPlaceholders(arrayPlaceholderMap, 'ASSIGNEES', pr.assignees || []) - placeholderMap.set('ASSIGNEES', pr.assignees?.join(', ') || '') - fillArrayPlaceholders(arrayPlaceholderMap, 'REVIEWERS', pr.requestedReviewers || []) - placeholderMap.set('REVIEWERS', pr.requestedReviewers?.join(', ') || '') - fillArrayPlaceholders(arrayPlaceholderMap, 'APPROVERS', pr.approvedReviewers || []) - placeholderMap.set('APPROVERS', pr.approvedReviewers?.join(', ') || '') - placeholderMap.set('BRANCH', pr.branch || '') - placeholderMap.set('BASE_BRANCH', pr.baseBranch) - return replacePlaceholders(template, arrayPlaceholderMap, placeholderMap, placeholders, placeholderPrMap, configuration) +function buildPrTemplateContext(pr: PullRequestData): [TemplateContext, TemplateContext] { + const prTemplateContext = new TemplateContext() + + prTemplateContext.set('NUMBER', pr.number.toString()) + prTemplateContext.set('TITLE', pr.title) + prTemplateContext.set('URL', pr.htmlURL) + prTemplateContext.set('STATUS', pr.status) + prTemplateContext.set('CREATED_AT', pr.createdAt.toISOString()) + prTemplateContext.set('MERGED_AT', pr.mergedAt?.toISOString() || '') + prTemplateContext.set('MERGE_SHA', pr.mergeCommitSha) + prTemplateContext.set('AUTHOR', pr.author) + prTemplateContext.set('AUTHOR_NAME', pr.authorName || '') + prTemplateContext.set('LABELS', [...pr.labels]?.filter(l => !l.startsWith('--rcba-'))?.join(', ') || '') + prTemplateContext.set('MILESTONE', pr.milestone || '') + prTemplateContext.set('BODY', pr.body) + prTemplateContext.set('ASSIGNEES', pr.assignees?.join(', ') || '') + prTemplateContext.set('REVIEWERS', pr.requestedReviewers?.join(', ') || '') + prTemplateContext.set('APPROVERS', pr.approvedReviewers?.join(', ') || '') + prTemplateContext.set('BRANCH', pr.branch || '') + prTemplateContext.set('BASE_BRANCH', pr.baseBranch) + + const prArrayTemplateContext = new TemplateContext() + fillReviewPlaceholders(prArrayTemplateContext, 'REVIEWS', pr.reviews || []) + fillChildPrPlaceholders(prArrayTemplateContext, 'REFERENCED', pr.childPrs || []) + fillArrayPlaceholders(prArrayTemplateContext, 'ASSIGNEES', pr.assignees || []) + fillArrayPlaceholders(prArrayTemplateContext, 'REVIEWERS', pr.requestedReviewers || []) + fillArrayPlaceholders(prArrayTemplateContext, 'APPROVERS', pr.approvedReviewers || []) + + return [prTemplateContext, prArrayTemplateContext] } -function replacePlaceholders( +function renderTemplateAndFillPlaceholderContext( template: string, - arrayPlaceholderMap: Map /* arrayPlaceholderKey and original value */, - placeholderMap: Map /* placeholderKey and original value */, - placeholders: Map /* placeholders to apply */, - placeholderPrMap: Map | undefined /* map to keep replaced placeholder values with their key */, + templateContext: TemplateContext /* placeholderKey and original value */, + customPlaceholders: PlaceholderGroup /* placeholders to apply */, + customPlaceholdersTemplateContext: GroupedTemplateContext | undefined /* map to keep replaced placeholder values with their key */, configuration: Configuration ): string { let transformed = template - // replace array placeholders first - for (const [key, value] of arrayPlaceholderMap) { - transformed = handlePlaceholder(transformed, key, value, placeholders, placeholderPrMap, configuration) - } - + const trimValues = configuration.trim_values // replace traditional placeholders - for (const [key, value] of placeholderMap) { - transformed = handlePlaceholder(transformed, key, value, placeholders, placeholderPrMap, configuration) + for (const [key, value] of templateContext) { + transformed = transformed.replaceAll(`#{{${key}}}`, trimValues ? value.trim() : value) + + const extractedValues = extractPlaceholderValuesAndFillPlaceholderContext( + key, + value, + customPlaceholders, + customPlaceholdersTemplateContext + ) + + for (const [placeholderName, extractedValue] of extractedValues) { + transformed = transformed.replaceAll(`#{{${placeholderName}}}`, trimValues ? extractedValue.trim() : extractedValue) + } } return transformed } -function handlePlaceholder( - template: string, +function extractPlaceholderValuesAndFillPlaceholderContext( key: string, value: string, - placeholders: Map /* placeholders to apply */, - placeholderPrMap: Map | undefined /* map to keep replaced placeholder values with their key */, - configuration: Configuration -): string { - let transformed = template.replaceAll(`#{{${key}}}`, configuration.trim_values ? value.trim() : value) - // replace custom placeholders - const phs = placeholders.get(key) - if (phs) { - for (const placeholder of phs) { - const transformer = validateRegex(placeholder.transformer) - if (transformer?.pattern) { - const extractedValue = transformStringToOptionalValue(value, transformer) - // note: `.replace` will return the full string again if there was no match - // note: This is mostly backwards compatibility - if (extractedValue && ((transformer.method && transformer.method !== 'replace') || extractedValue !== value)) { - if (placeholderPrMap) { - createOrSet(placeholderPrMap, placeholder.name, extractedValue) - } - transformed = transformed.replaceAll( - `#{{${placeholder.name}}}`, - configuration.trim_values ? extractedValue.trim() : extractedValue - ) + customPlaceholders: PlaceholderGroup, + customPlaceholdersTemplateContext: GroupedTemplateContext | undefined +): TemplateContext { + // Replace custom placeholders + const placeholdersForKey = customPlaceholders.get(key) - if (core.isDebug()) { - core.debug(` Custom Placeholder successfully matched data - ${extractedValue} (${placeholder.name})`) - } - } else if (core.isDebug() && extractedValue === value) { - core.debug(` Custom Placeholder did result in the full original value returned. Skipping. (${placeholder.name})`) - } + const extractedValues = new TemplateContext() + + if (!placeholdersForKey) { + return extractedValues + } + + for (const placeholder of placeholdersForKey) { + const extractedValue = extractTransformedValue(value, placeholder) + + if (extractedValue) { + extractedValues.set(placeholder.name, extractedValue) + + if (customPlaceholdersTemplateContext) { + createOrSet(customPlaceholdersTemplateContext, placeholder.name, extractedValue) } } } - return transformed + + return extractedValues +} + +function extractTransformedValue(value: string, placeholder: Placeholder): string | undefined { + const transformer = validateRegex(placeholder.transformer) + + if (!transformer?.pattern) { + return undefined + } + + const extractedValue = transformStringToOptionalValue(value, transformer) + + if (extractedValue && ((transformer.method && transformer.method !== 'replace') || extractedValue !== value)) { + if (core.isDebug()) { + core.debug(` Custom Placeholder successfully matched data - ${extractedValue} (${placeholder.name})`) + } + return extractedValue + } + + if (core.isDebug() && extractedValue === value) { + core.debug(` Custom Placeholder did result in the full original value returned. Skipping. (${placeholder.name})`) + } + + return undefined } function fillArrayPlaceholders( - placeholderMap: Map /* placeholderKey and original value */, + placeholderMap: TemplateContext /* placeholderKey and original value */, key: string, values: string[] ): void { @@ -562,7 +710,7 @@ function fillArrayPlaceholders( } function fillReviewPlaceholders( - placeholderMap: Map /* placeholderKey and original value */, + placeholderMap: TemplateContext /* placeholderKey and original value */, parentKey: string, values: CommentInfo[] ): void { @@ -580,7 +728,7 @@ function fillReviewPlaceholders( } function fillChildPrPlaceholders( - placeholderMap: Map /* placeholderKey and original value */, + placeholderMap: TemplateContext /* placeholderKey and original value */, parentKey: string, values: PullRequestInfo[] ): void { @@ -597,13 +745,13 @@ function fillChildPrPlaceholders( } } -function replacePrPlaceholders( +function renderTemplateWithContext( template: string, - placeholderPrMap: Map /* map with all pr related custom placeholder values */, + templateContext: GroupedTemplateContext /* map with all pr related custom placeholder values */, configuration: Configuration ): string { let transformed = template - for (const [key, values] of placeholderPrMap) { + for (const [key, values] of templateContext) { for (let i = 0; i < values.length; i++) { transformed = transformed.replaceAll(`#{{${key}[${i}]}}`, configuration.trim_values ? values[i].trim() : values[i]) } @@ -612,7 +760,7 @@ function replacePrPlaceholders( return transformed } -function cleanupPrPlaceholders(template: string, placeholders: Map): string { +function cleanupPrPlaceholders(template: string, placeholders: PlaceholderGroup): string { let transformed = template for (const [, phs] of placeholders) { for (const ph of phs) { @@ -630,6 +778,27 @@ function cleanupPlaceholders(template: string): string { return transformed } +function transformObject(obj: Record, transformers: RegexTransformer[]): void { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined) { + continue + } + if (Array.isArray(value)) { + if (value.every(item => typeof item === 'string')) { + // If the array contains only strings, apply the transformation to each string + obj[key] = value.map(item => transform(item, transformers)) + } else { + // If the array contains objects, recursively apply the transformation to each object + for (const child of value) { + transformObject(child as Record, transformers) + } + } + } else if (typeof value === 'string') { + obj[key] = transform(value, transformers) + } + } +} + function transform(filled: string, transformers: RegexTransformer[]): string { if (transformers.length === 0) { return filled diff --git a/src/utils.ts b/src/utils.ts index e3c9884..fc462d7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import * as core from '@actions/core' import * as fs from 'fs' import * as path from 'path' -import {Configuration, DefaultCommitConfiguration, DefaultConfiguration} from './configuration' +import {Configuration, DefaultCommitConfiguration, DefaultConfiguration, Placeholder} from './configuration' import moment from 'moment' import {DiffInfo} from './pr-collector/commits' import {PullRequestInfo} from './pr-collector/pullRequests' @@ -123,7 +123,7 @@ export function resolveMode(mode: string | undefined, commitMode: boolean): 'PR' } if (mode !== undefined) { - const upperCaseMode = mode.toUpperCase(); + const upperCaseMode = mode.toUpperCase() if (upperCaseMode === 'COMMIT') { return 'COMMIT' } else if (upperCaseMode === 'HYBRID') { @@ -243,6 +243,27 @@ export function createOrSet(map: Map, key: string, value: T): vo } } +/** + * Groups an array of Placeholder objects by their source attribute. + * + * @param {Placeholder[]} placeholders - An array of Placeholder objects to be grouped. + * @returns {Map} A map where the key is the source attribute of the Placeholder objects, + * and the value is an array of Placeholder objects that share the same source attribute. + */ +export function groupPlaceholders(placeholders: Placeholder[]): Map { + const map = new Map() + for (const ph of placeholders) { + const key = ph.source + const entry = map.get(key) + if (entry === undefined) { + map.set(key, [ph]) + } else { + entry.push(ph) + } + } + return map +} + export function haveCommonElements(arr1: string[], arr2: Set): boolean { return arr1.some(item => arr2.has(item)) } @@ -258,3 +279,7 @@ export function haveEveryElements(arr1: string[], arr2: Set): boolean { export function haveEveryElementsArr(arr1: string[], arr2: string[]): boolean { return haveEveryElements(arr1, new Set(arr2)) } + +export function mergeMaps(map1: Map, map2: Map): Map { + return new Map([...map1, ...map2]) +}