From 3b9332f01c58ff747887d087ecccb55e2f9de7f7 Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:05:05 +0200 Subject: [PATCH 01/10] add pattern helper module --- src/pattern.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/pattern.ts diff --git a/src/pattern.ts b/src/pattern.ts new file mode 100644 index 000000000..12812b612 --- /dev/null +++ b/src/pattern.ts @@ -0,0 +1,25 @@ +export interface RegexPattern { + regex: RegExp +} +export type LiteralPattern = string +export type Pattern = RegexPattern | LiteralPattern + +export function matchesPattern (pattern: Pattern, input: string) { + if (typeof pattern === 'string') { + return pattern === input + } else if ('regex' in pattern) { + return pattern.regex.test(input) + } else { + throw new Error(`Invalid pattern at runtime: ${typeof pattern}`) + } +} + +export function stringifyPattern (pattern: Pattern) { + if (typeof pattern === 'string') { + return pattern; + } else if ('regex' in pattern) { + return `{regex: ${pattern.regex}}` + } else { + return '[invalid pattern]' + } +} From 4f12d08009d34ee2f6299e08f72ec392e0652afe Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:05:48 +0200 Subject: [PATCH 02/10] feat: add regex support to required and blocking labels --- src/conditions/blockingLabels.ts | 8 +++++--- src/conditions/requiredLabels.ts | 12 +++++++----- src/config.ts | 18 ++++++++++++++---- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/conditions/blockingLabels.ts b/src/conditions/blockingLabels.ts index fb41ff169..b8da38a90 100644 --- a/src/conditions/blockingLabels.ts +++ b/src/conditions/blockingLabels.ts @@ -1,14 +1,16 @@ import { ConditionConfig } from './../config' import { PullRequestInfo } from '../models' import { ConditionResult } from '../condition' +import { matchesPattern } from '../pattern' export default function doesNotHaveBlockingLabels ( config: ConditionConfig, pullRequestInfo: PullRequestInfo ): ConditionResult { - const pullRequestLabels = new Set(pullRequestInfo.labels.nodes.map(label => label.name)) - const foundBlockingLabels = config.blockingLabels - .filter(blockingLabel => pullRequestLabels.has(blockingLabel)) + const pullRequestLabels = pullRequestInfo.labels.nodes.map(label => label.name) + + const foundBlockingLabels = pullRequestLabels + .filter(pullRequestLabel => config.blockingLabels.some(blockingLabelPattern => matchesPattern(blockingLabelPattern, pullRequestLabel))) if (foundBlockingLabels.length > 0) { return { diff --git a/src/conditions/requiredLabels.ts b/src/conditions/requiredLabels.ts index d5b3fe635..0e0dec264 100644 --- a/src/conditions/requiredLabels.ts +++ b/src/conditions/requiredLabels.ts @@ -1,21 +1,23 @@ + import { ConditionConfig } from './../config' import { PullRequestInfo } from '../models' import { ConditionResult } from '../condition' +import { matchesPattern, stringifyPattern } from '../pattern' export default function hasRequiredLabels ( config: ConditionConfig, pullRequestInfo: PullRequestInfo ): ConditionResult { - const pullRequestLabels = new Set(pullRequestInfo.labels.nodes.map(label => label.name)) + const pullRequestLabels = pullRequestInfo.labels.nodes.map(label => label.name) - const missingRequiredLabels = config.requiredLabels - .filter(requiredLabel => !pullRequestLabels.has(requiredLabel)) + const missingRequiredLabelPatterns = config.requiredLabels + .filter(requiredLabelPattern => !pullRequestLabels.some(pullRequestLabel => matchesPattern(requiredLabelPattern, pullRequestLabel))) - if (missingRequiredLabels.length > 0) { + if (missingRequiredLabelPatterns.length > 0) { return { status: 'fail', message: `Required labels are missing (${ - missingRequiredLabels.join(', ') + missingRequiredLabelPatterns.map(stringifyPattern).join(', ') })` } } diff --git a/src/config.ts b/src/config.ts index dc7512520..69acb19d2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { CommentAuthorAssociation } from './github-models' import { Context } from 'probot' import getConfig from 'probot-config' import { Decoder, object, string, optional, number, boolean, array, oneOf, constant } from '@mojotech/json-type-validation' +import { Pattern } from './pattern' class ConfigNotFoundError extends Error { constructor ( @@ -28,8 +29,8 @@ export class ConfigValidationError extends Error { export type ConditionConfig = { minApprovals: { [key in CommentAuthorAssociation]?: number }, maxRequestedChanges: { [key in CommentAuthorAssociation]?: number }, - requiredLabels: string[], - blockingLabels: string[], + requiredLabels: Pattern[], + blockingLabels: Pattern[], blockingBodyRegex: string | undefined requiredBodyRegex: string | undefined blockingTitleRegex: string | undefined @@ -78,11 +79,20 @@ const reviewConfigDecover: Decoder<{ [key in CommentAuthorAssociation]: number | NONE: optional(number()) }) +const regexDecoder = string().map(value => new RegExp(value)) + +const patternDecoder = oneOf( + string(), + object({ + regex: regexDecoder + }) +) + const conditionConfigDecoder: Decoder = object({ minApprovals: reviewConfigDecover, maxRequestedChanges: reviewConfigDecover, - requiredLabels: array(string()), - blockingLabels: array(string()), + requiredLabels: array(patternDecoder), + blockingLabels: array(patternDecoder), blockingTitleRegex: optional(string()), blockingBodyRegex: optional(string()), requiredTitleRegex: optional(string()), From 13c0f010a9e9fbb70bbd08ea279ba66fa3479a7a Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:44:37 +0200 Subject: [PATCH 03/10] refactor: move patternDecoder to patterns module --- src/config.ts | 15 +++------------ src/pattern.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/config.ts b/src/config.ts index 69acb19d2..8fd852356 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { CommentAuthorAssociation } from './github-models' import { Context } from 'probot' import getConfig from 'probot-config' import { Decoder, object, string, optional, number, boolean, array, oneOf, constant } from '@mojotech/json-type-validation' -import { Pattern } from './pattern' +import { Pattern, patternDecoder } from './pattern' class ConfigNotFoundError extends Error { constructor ( @@ -79,15 +79,6 @@ const reviewConfigDecover: Decoder<{ [key in CommentAuthorAssociation]: number | NONE: optional(number()) }) -const regexDecoder = string().map(value => new RegExp(value)) - -const patternDecoder = oneOf( - string(), - object({ - regex: regexDecoder - }) -) - const conditionConfigDecoder: Decoder = object({ minApprovals: reviewConfigDecover, maxRequestedChanges: reviewConfigDecover, @@ -103,8 +94,8 @@ const configDecoder: Decoder = object({ rules: array(conditionConfigDecoder), minApprovals: reviewConfigDecover, maxRequestedChanges: reviewConfigDecover, - requiredLabels: array(string()), - blockingLabels: array(string()), + requiredLabels: array(patternDecoder), + blockingLabels: array(patternDecoder), blockingTitleRegex: optional(string()), blockingBodyRegex: optional(string()), requiredTitleRegex: optional(string()), diff --git a/src/pattern.ts b/src/pattern.ts index 12812b612..9f2f5a4f4 100644 --- a/src/pattern.ts +++ b/src/pattern.ts @@ -1,3 +1,5 @@ +import { string, oneOf, object } from "@mojotech/json-type-validation" + export interface RegexPattern { regex: RegExp } @@ -23,3 +25,12 @@ export function stringifyPattern (pattern: Pattern) { return '[invalid pattern]' } } + +export const regexDecoder = string().map(value => new RegExp(value)) + +export const patternDecoder = oneOf( + string(), + object({ + regex: regexDecoder + }) +) From 4656a2183d45aa75e3bead8d4a6a6a236179ddbf Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:44:58 +0200 Subject: [PATCH 04/10] test matchesPattern and stringifyPattern --- test/pattern.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/pattern.test.ts diff --git a/test/pattern.test.ts b/test/pattern.test.ts new file mode 100644 index 000000000..c671217da --- /dev/null +++ b/test/pattern.test.ts @@ -0,0 +1,36 @@ +import { matchesPattern, stringifyPattern } from '../src/pattern' + +describe('matchesPattern', () => { + describe('while using literal pattern', () => { + it('should return true when pattern equals input', () => { + expect(matchesPattern('word', 'word')).toBe(true) + }) + it('should return false when input matches only partially', () => { + expect(matchesPattern('word', 'abc word abc')).toBe(false) + }) + it('should return false when input has different casing', () => { + expect(matchesPattern('word', 'Word')).toBe(false) + }) + }) + + describe('while using regex pattern', () => { + it('should return true when pattern matches input', () => { + expect(matchesPattern({ regex: /word/ }, 'word')).toBe(true) + }) + it('should return true when input has part of pattern', () => { + expect(matchesPattern({ regex: /word/ }, 'abc word abc')).toBe(true) + }) + it('should return false when input has different casing', () => { + expect(matchesPattern({ regex: /word/ }, 'Word')).toBe(false) + }) + }) +}) + +describe('stringifyPattern', () => { + it('should stringify literal patterns', () => { + expect(stringifyPattern('pattern')).toBe('pattern') + }) + it('should stringify regex patterns', () => { + expect(stringifyPattern({ regex: /pattern/ })).toBe('{regex: /pattern/}') + }) +}) From 95c210b5fdb4e706fdefd52fb9d51a395316809c Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:45:22 +0200 Subject: [PATCH 05/10] test: check regex functionality in requiredLabels --- test/conditions/requiredLabels.test.ts | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/conditions/requiredLabels.test.ts b/test/conditions/requiredLabels.test.ts index 9e274c7bf..1f2297dce 100644 --- a/test/conditions/requiredLabels.test.ts +++ b/test/conditions/requiredLabels.test.ts @@ -97,4 +97,36 @@ describe('open', () => { ) expect(result.status).toBe('fail') }) + + it('returns success with label matching regex in configuration', () => { + const result = requiredLabels( + createConditionConfig({ + requiredLabels: [{ regex: /required/ }] + }), + createPullRequestInfo({ + labels: { + nodes: [{ + name: 'required label' + }] + } + }) + ) + expect(result.status).toBe('success') + }) + + it('returns fail with label matching regex in configuration', () => { + const result = requiredLabels( + createConditionConfig({ + requiredLabels: [{ regex: /non matching/ }] + }), + createPullRequestInfo({ + labels: { + nodes: [{ + name: 'label' + }] + } + }) + ) + expect(result.status).toBe('fail') + }) }) From 3231fa91ba72d68976077925cadc027d7cd0b13c Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:46:09 +0200 Subject: [PATCH 06/10] test: remove changed expectation on blockingLabel configuration --- test/config.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/config.test.ts b/test/config.test.ts index 4b2ba8bb1..fce2f5418 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -33,7 +33,6 @@ describe('Config', () => { })() expect(validationError).not.toBeUndefined() expect(validationError.config.blockingLabels[1]).toEqual(userConfig.blockingLabels[1]) - expect(validationError.decoderError.message).toEqual('expected a string, got an object') expect(validationError.decoderError.at).toEqual('input.blockingLabels[1]') }) }) From 4e6ca1343b86b408e574768f097389a6b10f3db8 Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:46:23 +0200 Subject: [PATCH 07/10] test: parsing of regex patterns in configuration --- test/config.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/config.test.ts b/test/config.test.ts index fce2f5418..1a6a1cc0d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -20,6 +20,13 @@ describe('Config', () => { expect(config.rules[0].maxRequestedChanges.NONE).toBe(defaultConfig.maxRequestedChanges.NONE) }) + it('will parse regex patterns', () => { + const config = getConfigFromUserConfig({ + requiredLabels: [{ regex: 'regex' }] + }) as any + expect(config.requiredLabels[0].regex).toBeInstanceOf(RegExp) + }) + it('will throw validation error on incorrect configuration', () => { const userConfig = { blockingLabels: ['labela', { labelb: 'labelc' }] From 0601e6ee946eaa3e3a983f53107db7a95b6b8f48 Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 16:53:52 +0200 Subject: [PATCH 08/10] test: check regex functionality in blockingLabels --- test/conditions/blockingLabels.test.ts | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/conditions/blockingLabels.test.ts b/test/conditions/blockingLabels.test.ts index 24ff44445..00347bba5 100644 --- a/test/conditions/blockingLabels.test.ts +++ b/test/conditions/blockingLabels.test.ts @@ -63,4 +63,36 @@ describe('blockingLabels', () => { ) expect(result.status).toBe('fail') }) + + it('returns fail with label matching regex in configuration', () => { + const result = blockingLabels( + createConditionConfig({ + blockingLabels: [{ regex: /blocking/ }] + }), + createPullRequestInfo({ + labels: { + nodes: [{ + name: 'blocking label' + }] + } + }) + ) + expect(result.status).toBe('fail') + }) + + it('returns success with label not matching regex in configuration', () => { + const result = blockingLabels( + createConditionConfig({ + blockingLabels: [{ regex: /^blocking$/ }] + }), + createPullRequestInfo({ + labels: { + nodes: [{ + name: 'blocking label' + }] + } + }) + ) + expect(result.status).toBe('success') + }) }) From d6f4bdd9e9e0771ce606e42dc007c51e52ac3717 Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Sat, 15 Aug 2020 17:04:13 +0200 Subject: [PATCH 09/10] add documentation on regex to readme --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 8e7a72045..aaa05f3d2 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,16 @@ blockingLabels: - blocked ``` +The above example denotes literal label names. Regular expressions can be used to +partially match labels. This can be specified by the `regex:` property in the +configuration. The following example will block merging when a label is added that +starts with the text `blocked`: + +```yaml +blockingLabels: +- regex: ^blocked +``` + Note: remove the whole section when you're not using blocking labels. ### `requiredLabels` (condition, default: none) @@ -122,6 +132,15 @@ requiredLabels: - merge ``` +The above example denotes literal label names. Regular expressions can be used to +partially match labels. This requires `regex:` property in the configuration. The +following example will requires at least one label that starts with `merge`: + +```yaml +requiredLabels: +- regex: ^merge +``` + Note: remove the whole section when you're not using required labels. ### `blockingTitleRegex` (condition, default: none) From 36eb03281415f3e4ebf7fba676772e1d15592b91 Mon Sep 17 00:00:00 2001 From: Bob van der Linden Date: Wed, 19 Aug 2020 22:45:59 +0200 Subject: [PATCH 10/10] Update src/conditions/requiredLabels.ts Co-authored-by: Roger Oba --- src/conditions/requiredLabels.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/conditions/requiredLabels.ts b/src/conditions/requiredLabels.ts index 0e0dec264..5151cce6c 100644 --- a/src/conditions/requiredLabels.ts +++ b/src/conditions/requiredLabels.ts @@ -1,4 +1,3 @@ - import { ConditionConfig } from './../config' import { PullRequestInfo } from '../models' import { ConditionResult } from '../condition'