Skip to content

Commit

Permalink
feat(match): matches by PR title and body
Browse files Browse the repository at this point in the history
documentation
  • Loading branch information
Karel Alvarez committed Sep 27, 2023
1 parent 2b2e7d0 commit 1884eaf
Show file tree
Hide file tree
Showing 4 changed files with 353 additions and 33 deletions.
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
156 changes: 152 additions & 4 deletions __tests__/labeler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
89 changes: 73 additions & 16 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -257,39 +281,72 @@ 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;
}
}
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;
}
}
Expand Down
Loading

0 comments on commit 1884eaf

Please sign in to comment.