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: add config parameter for predicate quantifier #224

Merged
merged 1 commit into from Mar 2, 2024
Merged
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
42 changes: 42 additions & 0 deletions README.md
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
@@ -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
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
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