From 793329d870817ec154e166ef14a2b858f4432480 Mon Sep 17 00:00:00 2001 From: Mike Penz Date: Tue, 3 Jan 2023 17:29:56 +0000 Subject: [PATCH] - introduce new `rules` for the `Category` - the rules can be used to match specified `Properties` with a `RegExp` (uses `test`) - to add them into a category - This allows more complex changelogs to be constructed, which for example want all `OPEN` PRs in one category --- __tests__/transform.test.ts | 2 +- src/configuration.ts | 26 +++++++-- src/pullRequests.ts | 18 ++++++- src/regexUtils.ts | 101 +++++++++++++++++++++++++++++++++++ src/tags.ts | 2 +- src/transform.ts | 103 ++++++++++++------------------------ src/utils.ts | 3 +- src/wait.ts | 9 ---- 8 files changed, 179 insertions(+), 85 deletions(-) create mode 100644 src/regexUtils.ts delete mode 100644 src/wait.ts diff --git a/__tests__/transform.test.ts b/__tests__/transform.test.ts index 620941e..aea329b 100644 --- a/__tests__/transform.test.ts +++ b/__tests__/transform.test.ts @@ -1,7 +1,7 @@ import {buildChangelog} from '../src/transform' import {PullRequestInfo} from '../src/pullRequests' import moment from 'moment' -import {Configuration, DefaultConfiguration} from '../src/configuration' +import {DefaultConfiguration} from '../src/configuration' import {DefaultDiffInfo} from '../src/commits' jest.setTimeout(180000) diff --git a/src/configuration.ts b/src/configuration.ts index 670b380..e233d85 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -20,12 +20,32 @@ export interface Configuration { export interface Category { title: string // the title of this category - labels: string[] // labels to associate PRs to this category + labels?: string[] // labels to associate PRs to this category exclude_labels?: string[] // if an exclude label is detected, the PR will be excluded from this category - exhaustive?: boolean // requires all labels to be present in the PR + rules?: Rule[] // rules to associate PRs to this category + exhaustive?: boolean // requires all labels AND/OR rules to be present in the PR empty_content?: string // if the category has no matching PRs, this content will be used. If not set, the category will be skipped in the changelog. } +/** + * Defines the properties of the PullRequestInfo useable in different configurations + */ +export type Property = + | 'title' + | 'branch' + | 'author' + | 'labels' + | 'milestone' + | 'body' + | 'assignees' + | 'requestedReviewers' + | 'approvedReviewers' + | 'status' + +export interface Rule extends Regex { + on_property?: Property // retrieve the property to apply the rule on +} + export interface Sort { order: 'ASC' | 'DESC' // the sorting order on_property: 'mergedAt' | 'title' // the property to sort on. (mergedAt falls back to createdAt) @@ -41,7 +61,7 @@ export interface Transformer extends Regex { } export interface Extractor extends Transformer { - on_property?: ('title' | 'author' | 'milestone' | 'body' | 'branch')[] | 'title' | 'author' | 'milestone' | 'body' | 'branch' | undefined // retrieve the property to extract the value from + on_property?: Property[] | Property | undefined // retrieve the property to extract the value from method?: 'replace' | 'match' | undefined // the method to use to extract the value, `match` will not use the `target` property on_empty?: string | undefined // in case the regex results in an empty string, this value is gonna be used instead (only for label_extractor currently) } diff --git a/src/pullRequests.ts b/src/pullRequests.ts index da83266..3674329 100755 --- a/src/pullRequests.ts +++ b/src/pullRequests.ts @@ -2,7 +2,7 @@ import * as core from '@actions/core' import {Octokit, RestEndpointMethodTypes} from '@octokit/rest' import {Unpacked} from './utils' import moment from 'moment' -import {Sort} from './configuration' +import {Property, Sort} from './configuration' export interface PullRequestInfo { number: number @@ -226,6 +226,22 @@ export function compare(a: PullRequestInfo, b: PullRequestInfo, sort: Sort): num } } +/** + * Helper function to retrieve a property from the PullRequestInfo + */ +export function retrieveProperty(pr: PullRequestInfo, property: Property, useCase: string): string { + let value: string | Set | string[] | undefined = pr[property] + if (value === undefined) { + core.warning(`⚠️ the provided property '${property}' for \`${useCase}\` is not valid. Fallback to 'body'`) + value = pr['body'] + } else if (value instanceof Set) { + value = Array.from(value).join(',') // join into single string + } else if (Array.isArray(value)) { + value = value.join(',') // join into single string + } + return value +} + // helper function to add a special open label to prs not merged. function attachSpeciaLabels(status: 'open' | 'merged', labels: Set): Set { labels.add(`--rcba-${status}`) diff --git a/src/regexUtils.ts b/src/regexUtils.ts new file mode 100644 index 0000000..2be690c --- /dev/null +++ b/src/regexUtils.ts @@ -0,0 +1,101 @@ +import * as core from '@actions/core' +import {Extractor, Property, Regex, Rule, Transformer} from './configuration' +import {PullRequestInfo, retrieveProperty} from './pullRequests' + +/** + * 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 => validateTransformer(rule)).filter(t => t !== null) as RegexTransformer[] + if (exhaustive) { + return transformers.every(transformer => { + return matches(pr, transformer, 'rule') + }) + } else { + return transformers.some(transformer => { + return matches(pr, transformer, 'rule') + }) + } +} + +/** + * Checks if the configured property results in a positive `test` with the regex. + */ +function matches(pr: PullRequestInfo, extractor: RegexTransformer, extractor_usecase: string): boolean { + if (extractor.pattern == null) { + return false + } + + if (extractor.onProperty !== undefined && extractor.onProperty.length === 1) { + const prop = extractor.onProperty[0] + const value = retrieveProperty(pr, prop, extractor_usecase) + return extractor.pattern.test(value) + } + return false +} + +export function validateTransformer(transformer?: Regex): RegexTransformer | null { + if (transformer === undefined) { + return null + } + try { + let target = undefined + if (transformer.hasOwnProperty('target')) { + target = (transformer as Transformer).target + } + + let onProperty = undefined + let method = undefined + let onEmpty = undefined + if (transformer.hasOwnProperty('method')) { + method = (transformer as Extractor).method + onEmpty = (transformer as Extractor).on_empty + onProperty = (transformer as Extractor).on_property + } else if (transformer.hasOwnProperty('on_property')) { + onProperty = (transformer as Extractor).on_property + } + // legacy handling, transform single value input to array + if (!Array.isArray(onProperty)) { + if (onProperty !== undefined) { + onProperty = [onProperty] + } + } + + return buildRegex(transformer, target, onProperty, method, onEmpty) + } catch (e) { + core.warning(`⚠️ Failed to validate transformer: ${transformer.pattern}`) + return null + } +} + +/** + * Constructs the RegExp, providing the configured Regex and additional values + */ +export function buildRegex( + regex: Regex, + target: string | undefined, + onProperty?: Property[] | undefined, + method?: 'replace' | 'match' | undefined, + onEmpty?: string | undefined +): RegexTransformer | null { + try { + return { + pattern: new RegExp(regex.pattern.replace('\\\\', '\\'), regex.flags ?? 'gu'), + target: target || '', + onProperty, + method, + onEmpty + } + } catch (e) { + core.warning(`⚠️ Bad replacer regex: ${regex.pattern}`) + return null + } +} + +export interface RegexTransformer { + pattern: RegExp | null + target: string + onProperty?: Property[] + method?: 'replace' | 'match' + onEmpty?: string +} diff --git a/src/tags.ts b/src/tags.ts index c7d7fc1..7c6920b 100755 --- a/src/tags.ts +++ b/src/tags.ts @@ -5,8 +5,8 @@ import {Octokit, RestEndpointMethodTypes} from '@octokit/rest' import {SemVer} from 'semver' import {TagResolver} from './configuration' import {createCommandManager} from './gitHelper' -import {RegexTransformer, validateTransformer} from './transform' import moment from 'moment' +import {RegexTransformer, validateTransformer} from './regexUtils' export interface TagResult { from: TagInfo | null diff --git a/src/transform.ts b/src/transform.ts index caf75d4..8fe418d 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,17 +1,10 @@ import * as core from '@actions/core' -import {Category, Configuration, Extractor, Placeholder, Transformer} from './configuration' -import {CommentInfo, EMPTY_COMMENT_INFO, PullRequestInfo, sortPullRequests} from './pullRequests' +import {Category, Configuration, Placeholder, Property, Transformer} from './configuration' +import {CommentInfo, EMPTY_COMMENT_INFO, PullRequestInfo, retrieveProperty, sortPullRequests} from './pullRequests' import {ReleaseNotesOptions} from './releaseNotes' import {DiffInfo} from './commits' import {createOrSet, haveCommonElements, haveEveryElements} from './utils' - -export interface RegexTransformer { - pattern: RegExp | null - target: string - onProperty?: ('title' | 'author' | 'milestone' | 'body' | 'status' | 'branch')[] | undefined - method?: 'replace' | 'match' | undefined - onEmpty?: string | undefined -} +import {matchesRules, RegexTransformer, validateTransformer} from './regexUtils' const EMPTY_MAP = new Map() @@ -128,23 +121,32 @@ export function buildChangelog(diffInfo: DiffInfo, prs: PullRequestInfo[], optio } } - if (category.exhaustive === true) { - if ( - haveEveryElements( - category.labels.map(lbl => lbl.toLocaleLowerCase('en')), - pr.labels - ) - ) { - pullRequests.push(body) - matched = true + if (category.labels !== undefined) { + if (category.exhaustive === true) { + if ( + haveEveryElements( + category.labels.map(lbl => lbl.toLocaleLowerCase('en')), + pr.labels + ) + ) { + pullRequests.push(body) + matched = true + } + } else { + if ( + haveCommonElements( + category.labels.map(lbl => lbl.toLocaleLowerCase('en')), + pr.labels + ) + ) { + pullRequests.push(body) + matched = true + } } - } else { - if ( - haveCommonElements( - category.labels.map(lbl => lbl.toLocaleLowerCase('en')), - pr.labels - ) - ) { + } + + if (category.rules !== undefined) { + if (matchesRules(category.rules, pr, category.exhaustive === true)) { pullRequests.push(body) matched = true } @@ -154,7 +156,11 @@ export function buildChangelog(diffInfo: DiffInfo, prs: PullRequestInfo[], optio if (!matched) { // we allow to have pull requests included in an "uncategorized" category for (const [category, pullRequests] of categorized) { - if (category.labels.length === 0) { + if ( + (category.labels === undefined || category.labels.length === 0) && + category.rules === undefined && + category.exclude_labels === undefined + ) { pullRequests.push(body) break } @@ -458,40 +464,6 @@ function validateTransformers(specifiedTransformers: Transformer[]): RegexTransf }) } -export function validateTransformer(transformer?: Transformer): RegexTransformer | null { - if (transformer === undefined) { - return null - } - try { - let onProperty = undefined - let method = undefined - let onEmpty = undefined - if (transformer.hasOwnProperty('on_property')) { - onProperty = (transformer as Extractor).on_property - method = (transformer as Extractor).method - onEmpty = (transformer as Extractor).on_empty - } - - // legacy handling, transform single value input to array - if (!Array.isArray(onProperty)) { - if (onProperty !== undefined) { - onProperty = [onProperty] - } - } - - return { - pattern: new RegExp(transformer.pattern.replace('\\\\', '\\'), transformer.flags ?? 'gu'), - target: transformer.target || '', - onProperty, - method, - onEmpty - } - } catch (e) { - core.warning(`⚠️ Bad replacer regex: ${transformer.pattern}`) - return null - } -} - function extractValues(pr: PullRequestInfo, extractor: RegexTransformer, extractor_usecase: string): string[] | null { if (extractor.pattern == null) { return null @@ -499,16 +471,11 @@ function extractValues(pr: PullRequestInfo, extractor: RegexTransformer, extract if (extractor.onProperty !== undefined) { let results: string[] = [] - const list: ('title' | 'author' | 'milestone' | 'body' | 'status' | 'branch')[] = extractor.onProperty + const list: Property[] = extractor.onProperty // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < list.length; i++) { const prop = list[i] - let value: string | undefined = pr[prop] - if (value === undefined) { - core.warning(`⚠️ the provided property '${extractor.onProperty}' for \`${extractor_usecase}\` is not valid`) - value = pr['body'] - } - + const value = retrieveProperty(pr, prop, extractor_usecase) const values = extractValuesFromString(value, extractor) if (values !== null) { results = results.concat(values) diff --git a/src/utils.ts b/src/utils.ts index 4a579b3..f4d83a7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,7 +2,6 @@ import * as core from '@actions/core' import * as fs from 'fs' import * as path from 'path' import {Configuration, DefaultConfiguration} from './configuration' - /** * Resolves the repository path, relatively to the GITHUB_WORKSPACE */ @@ -158,7 +157,7 @@ export function writeOutput(githubWorkspacePath: string, outputFile: string, cha export type Unpacked = T extends (infer U)[] ? U : T -export function createOrSet(map: Map, key: string, value: T): void { +export function createOrSet(map: Map, key: string, value: T): void { const entry = map.get(key) if (!entry) { map.set(key, [value]) diff --git a/src/wait.ts b/src/wait.ts deleted file mode 100644 index b169d9a..0000000 --- a/src/wait.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function wait(milliseconds: number): Promise { - return new Promise(resolve => { - if (isNaN(milliseconds)) { - throw new Error('milliseconds not a number') - } - - setTimeout(() => resolve('done!'), milliseconds) - }) -}