Skip to content

Commit

Permalink
feat: add regex support to requiredLabels and blockingLabels (#818)
Browse files Browse the repository at this point in the history
  • Loading branch information
probot-auto-merge[bot] authored Aug 19, 2020
2 parents d5f63fb + 26b971b commit f09f698
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 15 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions src/conditions/blockingLabels.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
11 changes: 6 additions & 5 deletions src/conditions/requiredLabels.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
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(', ')
})`
}
}
Expand Down
13 changes: 7 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, patternDecoder } from './pattern'

export class ConfigNotFoundError extends Error {
constructor (
Expand All @@ -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
Expand Down Expand Up @@ -81,8 +82,8 @@ const reviewConfigDecover: Decoder<{ [key in CommentAuthorAssociation]: number |
const conditionConfigDecoder: Decoder<ConditionConfig> = 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()),
Expand All @@ -93,8 +94,8 @@ const configDecoder: Decoder<Config> = 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()),
Expand Down
36 changes: 36 additions & 0 deletions src/pattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { string, oneOf, object } from "@mojotech/json-type-validation"

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]'
}
}

export const regexDecoder = string().map(value => new RegExp(value))

export const patternDecoder = oneOf<Pattern>(
string(),
object({
regex: regexDecoder
})
)
32 changes: 32 additions & 0 deletions test/conditions/blockingLabels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
32 changes: 32 additions & 0 deletions test/conditions/requiredLabels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
8 changes: 7 additions & 1 deletion test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]
Expand All @@ -33,7 +40,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]')
})
})
36 changes: 36 additions & 0 deletions test/pattern.test.ts
Original file line number Diff line number Diff line change
@@ -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/}')
})
})

0 comments on commit f09f698

Please sign in to comment.