Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(match): matches by PR title and body #688

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
39 changes: 38 additions & 1 deletion README.md
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
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
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