Skip to content

Commit

Permalink
Merge pull request #224 from petermetz/feat-filter-predicate-quantifier
Browse files Browse the repository at this point in the history
feat: add config parameter for predicate quantifier
  • Loading branch information
dorny committed Mar 2, 2024
2 parents ebc4d7e + f90d526 commit cf89abd
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 7 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
# changes using git commands.
# Default: ${{ github.token }}
token: ''

# Optional parameter to override the default behavior of file matching algorithm.
# By default files that match at least one pattern defined by the filters will be included.
# This parameter allows to override the "at least one pattern" behavior to make it so that
# all of the patterns have to match or otherwise the file is excluded.
# An example scenario where this is useful if you would like to match all
# .ts files in a sub-directory but not .md files.
# The filters below will match markdown files despite the exclusion syntax UNLESS
# you specify 'every' as the predicate-quantifier parameter. When you do that,
# it will only match the .ts files in the subdirectory as expected.
#
# backend:
# - 'pkg/a/b/c/**'
# - '!**/*.jpeg'
# - '!**/*.md'
predicate-quantifier: 'some'
```

## Outputs
Expand Down Expand Up @@ -463,6 +479,32 @@ jobs:

</details>

<details>
<summary>Detect changes in folder only for some file extensions</summary>

```yaml
- uses: dorny/paths-filter@v3
id: filter
with:
# This makes it so that all the patterns have to match a file for it to be
# considered changed. Because we have the exclusions for .jpeg and .md files
# the end result is that if those files are changed they will be ignored
# because they don't match the respective rules excluding them.
#
# This can be leveraged to ensure that you only build & test software changes
# that have real impact on the behavior of the code, e.g. you can set up your
# build to run when Typescript/Rust/etc. files are changed but markdown
# changes in the diff will be ignored and you consume less resources to build.
predicate-quantifier: 'every'
filters: |
backend:
- 'pkg/a/b/c/**'
- '!**/*.jpeg'
- '!**/*.md'
```

</details>

### Custom processing of changed files

<details>
Expand Down
39 changes: 38 additions & 1 deletion __tests__/filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Filter} from '../src/filter'
import {Filter, FilterConfig, PredicateQuantifier} from '../src/filter'
import {File, ChangeStatus} from '../src/file'

describe('yaml filter parsing tests', () => {
Expand Down Expand Up @@ -117,6 +117,37 @@ describe('matching tests', () => {
expect(pyMatch.backend).toEqual(pyFiles)
})

test('matches only files that are matching EVERY pattern when set to PredicateQuantifier.EVERY', () => {
const yaml = `
backend:
- 'pkg/a/b/c/**'
- '!**/*.jpeg'
- '!**/*.md'
`
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.EVERY}
const filter = new Filter(yaml, filterConfig)

const typescriptFiles = modified(['pkg/a/b/c/some-class.ts', 'pkg/a/b/c/src/main/some-class.ts'])
const otherPkgTypescriptFiles = modified(['pkg/x/y/z/some-class.ts', 'pkg/x/y/z/src/main/some-class.ts'])
const otherPkgJpegFiles = modified(['pkg/x/y/z/some-pic.jpeg', 'pkg/x/y/z/src/main/jpeg/some-pic.jpeg'])
const docsFiles = modified([
'pkg/a/b/c/some-pics.jpeg',
'pkg/a/b/c/src/main/jpeg/some-pic.jpeg',
'pkg/a/b/c/src/main/some-docs.md',
'pkg/a/b/c/some-docs.md'
])

const typescriptMatch = filter.match(typescriptFiles)
const otherPkgTypescriptMatch = filter.match(otherPkgTypescriptFiles)
const docsMatch = filter.match(docsFiles)
const otherPkgJpegMatch = filter.match(otherPkgJpegFiles)

expect(typescriptMatch.backend).toEqual(typescriptFiles)
expect(otherPkgTypescriptMatch.backend).toEqual([])
expect(docsMatch.backend).toEqual([])
expect(otherPkgJpegMatch.backend).toEqual([])
})

test('matches path based on rules included using YAML anchor', () => {
const yaml = `
shared: &shared
Expand Down Expand Up @@ -186,3 +217,9 @@ function modified(paths: string[]): File[] {
return {filename, status: ChangeStatus.Modified}
})
}

function renamed(paths: string[]): File[] {
return paths.map(filename => {
return {filename, status: ChangeStatus.Renamed}
})
}
55 changes: 51 additions & 4 deletions src/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,48 @@ interface FilterRuleItem {
isMatch: (str: string) => boolean // Matches the filename
}

/**
* Enumerates the possible logic quantifiers that can be used when determining
* if a file is a match or not with multiple patterns.
*
* The YAML configuration property that is parsed into one of these values is
* 'predicate-quantifier' on the top level of the configuration object of the
* action.
*
* The default is to use 'some' which used to be the hardcoded behavior prior to
* the introduction of the new mechanism.
*
* @see https://en.wikipedia.org/wiki/Quantifier_(logic)
*/
export enum PredicateQuantifier {
/**
* When choosing 'every' in the config it means that files will only get matched
* if all the patterns are satisfied by the path of the file, not just at least one of them.
*/
EVERY = 'every',
/**
* When choosing 'some' in the config it means that files will get matched as long as there is
* at least one pattern that matches them. This is the default behavior if you don't
* specify anything as a predicate quantifier.
*/
SOME = 'some'
}

/**
* Used to define customizations for how the file filtering should work at runtime.
*/
export type FilterConfig = {readonly predicateQuantifier: PredicateQuantifier}

/**
* An array of strings (at runtime) that contains the valid/accepted values for
* the configuration parameter 'predicate-quantifier'.
*/
export const SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier)

export function isPredicateQuantifier(x: unknown): x is PredicateQuantifier {
return SUPPORTED_PREDICATE_QUANTIFIERS.includes(x as PredicateQuantifier)
}

export interface FilterResults {
[key: string]: File[]
}
Expand All @@ -31,7 +73,7 @@ export class Filter {
rules: {[key: string]: FilterRuleItem[]} = {}

// Creates instance of Filter and load rules from YAML if it's provided
constructor(yaml?: string) {
constructor(yaml?: string, public readonly filterConfig?: FilterConfig) {
if (yaml) {
this.load(yaml)
}
Expand Down Expand Up @@ -62,9 +104,14 @@ export class Filter {
}

private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
return patterns.some(
rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
)
const aPredicate = (rule: Readonly<FilterRuleItem>) => {
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
}
if (this.filterConfig?.predicateQuantifier === 'every') {
return patterns.every(aPredicate)
} else {
return patterns.some(aPredicate)
}
}

private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
Expand Down
20 changes: 18 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import * as github from '@actions/github'
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'

import {Filter, FilterResults} from './filter'
import {
isPredicateQuantifier,
Filter,
FilterConfig,
FilterResults,
PredicateQuantifier,
SUPPORTED_PREDICATE_QUANTIFIERS
} from './filter'
import {File, ChangeStatus} from './file'
import * as git from './git'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
Expand All @@ -26,13 +33,22 @@ async function run(): Promise<void> {
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME

if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
return
}

const filter = new Filter(filtersYaml)
if (!isPredicateQuantifier(predicateQuantifier)) {
const predicateQuantifierInvalidErrorMsg =
`Input parameter 'predicate-quantifier' is set to invalid value ` +
`'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`
throw new Error(predicateQuantifierInvalidErrorMsg)
}
const filterConfig: FilterConfig = {predicateQuantifier}

const filter = new Filter(filtersYaml, filterConfig)
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
core.info(`Detected ${files.length} changed files`)
const results = filter.match(files)
Expand Down

0 comments on commit cf89abd

Please sign in to comment.