From 1884eafd169d853d0910b40d847b64943150b7a6 Mon Sep 17 00:00:00 2001 From: Karel Alvarez Date: Wed, 27 Sep 2023 15:19:26 +0300 Subject: [PATCH] feat(match): matches by PR title and body documentation --- README.md | 39 +++++++++- __tests__/labeler.test.ts | 156 +++++++++++++++++++++++++++++++++++++- dist/index.js | 89 ++++++++++++++++++---- src/labeler.ts | 102 ++++++++++++++++++++++--- 4 files changed, 353 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5ba6f3e0a..412bc9874 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ label1: From a boolean logic perspective, top-level match objects are `OR`-ed together and individual match rules within an object are `AND`-ed. Combined with `!` negation, you can write complex matching rules. -> ⚠️ This action uses [minimatch](https://www.npmjs.com/package/minimatch) to apply glob patterns. +> ⚠️ This action uses [minimatch](https://www.npmjs.com/package/minimatch) to apply glob patterns to the names of files changed. > For historical reasons, paths starting with dot (e.g. `.github`) are not matched by default. > You need to set `dot: true` to change this behavior. > See [Inputs](#inputs) table below for details. @@ -156,6 +156,43 @@ label1: - path/to/folder/** ``` + +##### Matching based on body or title +The match expression can also have the prefixes 'body:' or 'title:'. This are matched against the PR title and description. Can be combined like any other file name match expression. + + +Examples 1: + +```yml +slackNotify: +- "body:flagProduction" +``` + +Would add the label "slackNotify" if the PR has the text "flagProduction" somewhere in the description + +Examples 2: + +```yml +impactsRealease: +- all: + - "body:flagProduction" + - *.properties +``` + +Would add the label "impactsRelease" if the PR has the text "flagProduction" somewhere in the description, and affects any file with the extension "properties" + +Example 3: + +```yml +customer: +- all: + - "body:customer" + - "title:customer" +``` + +Would add the label customer if both the body and the title contain "customer" + + ##### Example workflow specifying Pull request numbers ```yml diff --git a/__tests__/labeler.test.ts b/__tests__/labeler.test.ts index 946d9ce18..8760ce7cb 100644 --- a/__tests__/labeler.test.ts +++ b/__tests__/labeler.test.ts @@ -15,29 +15,177 @@ const matchConfig = [{any: ['*.txt']}]; describe('checkGlobs', () => { it('returns true when our pattern does match changed files', () => { const changedFiles = ['foo.txt', 'bar.txt']; - const result = checkGlobs(changedFiles, matchConfig, false); + const result = checkGlobs('', '', changedFiles, matchConfig, false); expect(result).toBeTruthy(); }); it('returns false when our pattern does not match changed files', () => { const changedFiles = ['foo.docx']; - const result = checkGlobs(changedFiles, matchConfig, false); + const result = checkGlobs('', '', changedFiles, matchConfig, false); expect(result).toBeFalsy(); }); it('returns false for a file starting with dot if `dot` option is false', () => { const changedFiles = ['.foo.txt']; - const result = checkGlobs(changedFiles, matchConfig, false); + const result = checkGlobs('', '', changedFiles, matchConfig, false); expect(result).toBeFalsy(); }); it('returns true for a file starting with dot if `dot` option is true', () => { const changedFiles = ['.foo.txt']; - const result = checkGlobs(changedFiles, matchConfig, true); + const result = checkGlobs('', '', changedFiles, matchConfig, true); expect(result).toBeTruthy(); }); + + describe('by body', () => { + it('returns true when our pattern does match PR body', () => { + const anyBodyWithFooConfig = [{any: ['body:baz']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + '', + 'blah baz potato', + changedFiles, + anyBodyWithFooConfig, + false + ); + + expect(result).toBeTruthy(); + }); + + it('returns false when our pattern does not match PR body', () => { + const anyBodyWithBazConfig = [{any: ['body:bar']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + '', + 'blah bass potato', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeFalsy(); + }); + }); + describe('by title', () => { + it('returns true when our pattern does match PR title', () => { + const anyBodyWithFooConfig = [{any: ['title:baz']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + 'blah baz potato', + '', + changedFiles, + anyBodyWithFooConfig, + false + ); + + expect(result).toBeTruthy(); + }); + + it('returns false when our pattern does not match PR title', () => { + const anyBodyWithBazConfig = [{any: ['title:bar']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + 'blah bass potato', + '', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeFalsy(); + }); + }); + + describe('by body or title', () => { + it('returns true when our pattern does not match PR body, but matches a file', () => { + const anyBodyWithBazConfig = [{any: ['body:bar', 'bar.*']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + '', + 'blah bass potato', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeTruthy(); + }); + + it('returns true when our pattern does not match PR body but matches a title', () => { + const anyBodyWithBazConfig = [{any: ['body:bar', 'title:zoo']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + 'welcome to the zoo', + 'blah bass potato', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeTruthy(); + }); + + it('returns true when our pattern does not match PR body or title but matches a file', () => { + const anyBodyWithBazConfig = [ + {any: ['body:bar', 'title:potato', 'bar.*']} + ]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + 'welcome to the zoo', + 'blah bass potato', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeTruthy(); + }); + }); + + describe('by body and title', () => { + it('returns true when our pattern matches PR body and title', () => { + const anyBodyWithBazConfig = [{all: ['body:bass', 'title:bar']}]; + const result = checkGlobs( + 'some bar here', + 'blah bass potato', + [], + anyBodyWithBazConfig, + false + ); + + expect(result).toBeTruthy(); + }); + + it('returns true when our pattern matches PR body, title and files', () => { + const anyBodyWithBazConfig = [{all: ['body:bass', 'title:zoo', '*.txt']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + 'welcome to the zoo.', + 'blah bass potato', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeTruthy(); + }); + + it('returns false when our pattern does not match PR body, even if it matches files', () => { + const anyBodyWithBazConfig = [{all: ['body:not_here', '*.txt']}]; + const changedFiles = ['foo.txt', 'bar.txt']; + const result = checkGlobs( + 'welcome to the zoo', + 'blah bass potato', + changedFiles, + anyBodyWithBazConfig, + false + ); + + expect(result).toBeFalsy(); + }); + }); }); diff --git a/dist/index.js b/dist/index.js index c96d37a93..41e2a0477 100644 --- a/dist/index.js +++ b/dist/index.js @@ -90,7 +90,7 @@ function run() { const allLabels = new Set(preexistingLabels); for (const [label, globs] of labelGlobs.entries()) { core.debug(`processing ${label}`); - if (checkGlobs(changedFiles, globs, dot)) { + if (checkGlobs(pullRequest.title, pullRequest.body, changedFiles, globs, dot)) { allLabels.add(label); } else if (syncLabels) { @@ -233,17 +233,41 @@ function toMatchConfig(config) { function printPattern(matcher) { return (matcher.negate ? '!' : '') + matcher.pattern; } -function checkGlobs(changedFiles, globs, dot) { +function checkGlobs(prTitle, prBody, changedFiles, globs, dot) { for (const glob of globs) { core.debug(` checking pattern ${JSON.stringify(glob)}`); const matchConfig = toMatchConfig(glob); - if (checkMatch(changedFiles, matchConfig, dot)) { + if (checkMatch(prTitle, prBody, changedFiles, matchConfig, dot)) { return true; } } return false; } exports.checkGlobs = checkGlobs; +function isMatchTitle(prTitle, titleMatchers) { + core.debug(` matching patterns against title ${prTitle}`); + for (const titleMatcher of titleMatchers) { + core.debug(` - pattern ${titleMatcher}`); + if (!prTitle.includes(titleMatcher)) { + core.debug(` pattern ${titleMatcher} did not match`); + return false; + } + } + core.debug(` all patterns matched title`); + return true; +} +function isMatchBody(prBody, bodyMatchers) { + core.debug(` matching patterns against body ${prBody}`); + for (const bodyMatcher of bodyMatchers) { + core.debug(` - pattern ${bodyMatcher}`); + if (!prBody.includes(bodyMatcher)) { + core.debug(` pattern ${bodyMatcher} did not match`); + return false; + } + } + core.debug(` all patterns matched body`); + return true; +} function isMatch(changedFile, matchers) { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { @@ -257,24 +281,57 @@ function isMatch(changedFile, matchers) { return true; } // equivalent to "Array.some()" but expanded for debugging and clarity -function checkAny(changedFiles, globs, dot) { - const matchers = globs.map(g => new minimatch_1.Minimatch(g, { dot })); - core.debug(` checking "any" patterns`); - for (const changedFile of changedFiles) { - if (isMatch(changedFile, matchers)) { - core.debug(` "any" patterns matched against ${changedFile}`); - return true; +function checkAny(prTitle, prBody, changedFiles, globs, dot) { + const matchers = groupMatchers(globs, dot); + core.debug(` checking "any" patterns`); + if (matchers.byTitle.length > 0 && isMatchTitle(prTitle, matchers.byTitle)) { + core.debug(` "any" patterns matched against pr title ${prTitle}`); + return true; + } + if (matchers.byBody.length > 0 && isMatchBody(prBody, matchers.byBody)) { + core.debug(` "any" patterns matched against pr body ${prBody}`); + return true; + } + if (matchers.byFile.length > 0) { + for (const changedFile of changedFiles) { + if (isMatch(changedFile, matchers.byFile)) { + core.debug(` "any" patterns matched against ${changedFile}`); + return true; + } } } core.debug(` "any" patterns did not match any files`); return false; } +function groupMatchers(globs, dot) { + const grouped = { byBody: [], byTitle: [], byFile: [] }; + return globs.reduce((g, glob) => { + if (glob.startsWith('title:')) { + g.byTitle.push(glob.substring(6)); + } + else if (glob.startsWith('body:')) { + g.byBody.push(glob.substring(5)); + } + else { + g.byFile.push(new minimatch_1.Minimatch(glob, { dot })); + } + return g; + }, grouped); +} // equivalent to "Array.every()" but expanded for debugging and clarity -function checkAll(changedFiles, globs, dot) { - const matchers = globs.map(g => new minimatch_1.Minimatch(g, { dot })); +function checkAll(prTitle, prBody, changedFiles, globs, dot) { + const matchers = groupMatchers(globs, dot); core.debug(` checking "all" patterns`); + if (!isMatchTitle(prTitle, matchers.byTitle)) { + core.debug(` "all" patterns dit not match against pr title ${prTitle}`); + return false; + } + if (!isMatchBody(prBody, matchers.byBody)) { + core.debug(` "all" patterns dit not match against pr body ${prBody}`); + return false; + } for (const changedFile of changedFiles) { - if (!isMatch(changedFile, matchers)) { + if (!isMatch(changedFile, matchers.byFile)) { core.debug(` "all" patterns did not match against ${changedFile}`); return false; } @@ -282,14 +339,14 @@ function checkAll(changedFiles, globs, dot) { core.debug(` "all" patterns matched all files`); return true; } -function checkMatch(changedFiles, matchConfig, dot) { +function checkMatch(prTitle, prBody, changedFiles, matchConfig, dot) { if (matchConfig.all !== undefined) { - if (!checkAll(changedFiles, matchConfig.all, dot)) { + if (!checkAll(prTitle, prBody, changedFiles, matchConfig.all, dot)) { return false; } } if (matchConfig.any !== undefined) { - if (!checkAny(changedFiles, matchConfig.any, dot)) { + if (!checkAny(prTitle, prBody, changedFiles, matchConfig.any, dot)) { return false; } } diff --git a/src/labeler.ts b/src/labeler.ts index 272e3af2e..40b90b1c0 100644 --- a/src/labeler.ts +++ b/src/labeler.ts @@ -63,7 +63,15 @@ export async function run() { for (const [label, globs] of labelGlobs.entries()) { core.debug(`processing ${label}`); - if (checkGlobs(changedFiles, globs, dot)) { + if ( + checkGlobs( + pullRequest.title, + pullRequest.body, + changedFiles, + globs, + dot + ) + ) { allLabels.add(label); } else if (syncLabels) { allLabels.delete(label); @@ -244,6 +252,8 @@ function printPattern(matcher: Minimatch): string { } export function checkGlobs( + prTitle: string, + prBody: string, changedFiles: string[], globs: StringOrMatchConfig[], dot: boolean @@ -251,13 +261,39 @@ export function checkGlobs( for (const glob of globs) { core.debug(` checking pattern ${JSON.stringify(glob)}`); const matchConfig = toMatchConfig(glob); - if (checkMatch(changedFiles, matchConfig, dot)) { + if (checkMatch(prTitle, prBody, changedFiles, matchConfig, dot)) { return true; } } return false; } +function isMatchTitle(prTitle: string, titleMatchers: string[]): boolean { + core.debug(` matching patterns against title ${prTitle}`); + for (const titleMatcher of titleMatchers) { + core.debug(` - pattern ${titleMatcher}`); + if (!prTitle.includes(titleMatcher)) { + core.debug(` pattern ${titleMatcher} did not match`); + return false; + } + } + core.debug(` all patterns matched title`); + return true; +} + +function isMatchBody(prBody: string, bodyMatchers: string[]): boolean { + core.debug(` matching patterns against body ${prBody}`); + for (const bodyMatcher of bodyMatchers) { + core.debug(` - pattern ${bodyMatcher}`); + if (!prBody.includes(bodyMatcher)) { + core.debug(` pattern ${bodyMatcher} did not match`); + return false; + } + } + core.debug(` all patterns matched body`); + return true; +} + function isMatch(changedFile: string, matchers: Minimatch[]): boolean { core.debug(` matching patterns against file ${changedFile}`); for (const matcher of matchers) { @@ -274,16 +310,28 @@ function isMatch(changedFile: string, matchers: Minimatch[]): boolean { // equivalent to "Array.some()" but expanded for debugging and clarity function checkAny( + prTitle: string, + prBody: string, changedFiles: string[], globs: string[], dot: boolean ): boolean { - const matchers = globs.map(g => new Minimatch(g, {dot})); - core.debug(` checking "any" patterns`); - for (const changedFile of changedFiles) { - if (isMatch(changedFile, matchers)) { - core.debug(` "any" patterns matched against ${changedFile}`); - return true; + const matchers = groupMatchers(globs, dot); + core.debug(` checking "any" patterns`); + if (matchers.byTitle.length > 0 && isMatchTitle(prTitle, matchers.byTitle)) { + core.debug(` "any" patterns matched against pr title ${prTitle}`); + return true; + } + if (matchers.byBody.length > 0 && isMatchBody(prBody, matchers.byBody)) { + core.debug(` "any" patterns matched against pr body ${prBody}`); + return true; + } + if (matchers.byFile.length > 0) { + for (const changedFile of changedFiles) { + if (isMatch(changedFile, matchers.byFile)) { + core.debug(` "any" patterns matched against ${changedFile}`); + return true; + } } } @@ -291,16 +339,44 @@ function checkAny( return false; } +function groupMatchers(globs: string[], dot: boolean) { + const grouped: { + byTitle: string[]; + byBody: string[]; + byFile: Minimatch[]; + } = {byBody: [], byTitle: [], byFile: []}; + return globs.reduce((g, glob) => { + if (glob.startsWith('title:')) { + g.byTitle.push(glob.substring(6)); + } else if (glob.startsWith('body:')) { + g.byBody.push(glob.substring(5)); + } else { + g.byFile.push(new Minimatch(glob, {dot})); + } + return g; + }, grouped); +} + // equivalent to "Array.every()" but expanded for debugging and clarity function checkAll( + prTitle: string, + prBody: string, changedFiles: string[], globs: string[], dot: boolean ): boolean { - const matchers = globs.map(g => new Minimatch(g, {dot})); + const matchers = groupMatchers(globs, dot); core.debug(` checking "all" patterns`); + if (!isMatchTitle(prTitle, matchers.byTitle)) { + core.debug(` "all" patterns dit not match against pr title ${prTitle}`); + return false; + } + if (!isMatchBody(prBody, matchers.byBody)) { + core.debug(` "all" patterns dit not match against pr body ${prBody}`); + return false; + } for (const changedFile of changedFiles) { - if (!isMatch(changedFile, matchers)) { + if (!isMatch(changedFile, matchers.byFile)) { core.debug(` "all" patterns did not match against ${changedFile}`); return false; } @@ -311,18 +387,20 @@ function checkAll( } function checkMatch( + prTitle: string, + prBody: string, changedFiles: string[], matchConfig: MatchConfig, dot: boolean ): boolean { if (matchConfig.all !== undefined) { - if (!checkAll(changedFiles, matchConfig.all, dot)) { + if (!checkAll(prTitle, prBody, changedFiles, matchConfig.all, dot)) { return false; } } if (matchConfig.any !== undefined) { - if (!checkAny(changedFiles, matchConfig.any, dot)) { + if (!checkAny(prTitle, prBody, changedFiles, matchConfig.any, dot)) { return false; } }