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

Export files matching rules #32

Merged
merged 6 commits into from
Aug 30, 2020
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions .github/workflows/pull-request-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,24 +80,32 @@ jobs:
id: filter
with:
token: ''
list-files: shell
filters: |
add:
added:
- added: "add.txt"
rm:
deleted:
- deleted: "README.md"
modified:
- modified: "LICENSE"
any:
- added|deleted|modified: "*"
- name: Print changed files
run: echo '${{steps.filter.outputs.files}}' | jq .
- name: Print 'added_files'
run: echo ${{steps.filter.outputs.added_files}}
- name: Print 'modified_files'
run: echo ${{steps.filter.outputs.modified_files}}
- name: Print 'deleted_files'
run: echo ${{steps.filter.outputs.deleted_files}}
- name: filter-test
# only single quotes are supported in GH action literal
# single quote needs to be escaped with single quote
# '''add.txt''' resolves to string 'add.txt'
if: |
steps.filter.outputs.add != 'true'
|| steps.filter.outputs.rm != 'true'
steps.filter.outputs.added != 'true'
|| steps.filter.outputs.deleted != 'true'
|| steps.filter.outputs.modified != 'true'
|| steps.filter.outputs.any != 'true'
|| !contains(fromJSON(steps.filter.outputs.files).added,'add.txt')
|| !contains(fromJSON(steps.filter.outputs.files).modified,'LICENSE')
|| !contains(fromJSON(steps.filter.outputs.files).deleted,'README.md')
|| steps.filter.outputs.added_files != '''add.txt'''
|| steps.filter.outputs.modified_files != '''LICENSE'''
|| steps.filter.outputs.deleted_files != '''README.md'''
run: exit 1
58 changes: 34 additions & 24 deletions __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} from '../src/filter'
import {File, ChangeStatus} from '../src/file'

describe('yaml filter parsing tests', () => {
Expand All @@ -25,17 +25,19 @@ describe('matching tests', () => {
src: "src/**/*.js"
`
let filter = new Filter(yaml)
const match = filter.match(modified(['src/app/module/file.js']))
expect(match.src).toBeTruthy()
const files = modified(['src/app/module/file.js'])
const match = filter.match(files)
expect(match.src).toEqual(files)
})
test('matches single rule in single group', () => {
const yaml = `
src:
- src/**/*.js
`
const filter = new Filter(yaml)
const match = filter.match(modified(['src/app/module/file.js']))
expect(match.src).toBeTruthy()
const files = modified(['src/app/module/file.js'])
const match = filter.match(files)
expect(match.src).toEqual(files)
})

test('no match when file is in different folder', () => {
Expand All @@ -45,7 +47,7 @@ describe('matching tests', () => {
`
const filter = new Filter(yaml)
const match = filter.match(modified(['not_src/other_file.js']))
expect(match.src).toBeFalsy()
expect(match.src).toEqual([])
})

test('match only within second groups ', () => {
Expand All @@ -56,9 +58,10 @@ describe('matching tests', () => {
- test/**/*.js
`
const filter = new Filter(yaml)
const match = filter.match(modified(['test/test.js']))
expect(match.src).toBeFalsy()
expect(match.test).toBeTruthy()
const files = modified(['test/test.js'])
const match = filter.match(files)
expect(match.src).toEqual([])
expect(match.test).toEqual(files)
})

test('match only withing second rule of single group', () => {
Expand All @@ -68,18 +71,20 @@ describe('matching tests', () => {
- test/**/*.js
`
const filter = new Filter(yaml)
const match = filter.match(modified(['test/test.js']))
expect(match.src).toBeTruthy()
const files = modified(['test/test.js'])
const match = filter.match(files)
expect(match.src).toEqual(files)
})

test('matches anything', () => {
const yaml = `
any:
- "**/*"
- "**"
`
const filter = new Filter(yaml)
const match = filter.match(modified(['test/test.js']))
expect(match.any).toBeTruthy()
const files = modified(['test/test.js'])
const match = filter.match(files)
expect(match.any).toEqual(files)
})

test('globbing matches path where file or folder name starts with dot', () => {
Expand All @@ -88,8 +93,9 @@ describe('matching tests', () => {
- "**/*.js"
`
const filter = new Filter(yaml)
const match = filter.match(modified(['.test/.test.js']))
expect(match.dot).toBeTruthy()
const files = modified(['.test/.test.js'])
const match = filter.match(files)
expect(match.dot).toEqual(files)
})

test('matches path based on rules included using YAML anchor', () => {
Expand All @@ -101,9 +107,10 @@ describe('matching tests', () => {
- *shared
- src/**/*
`
let filter = new Filter(yaml)
const match = filter.match(modified(['config/settings.yml']))
expect(match.src).toBeTruthy()
const filter = new Filter(yaml)
const files = modified(['config/settings.yml'])
const match = filter.match(files)
expect(match.src).toEqual(files)
})
})

Expand All @@ -115,7 +122,7 @@ describe('matching specific change status', () => {
`
let filter = new Filter(yaml)
const match = filter.match(modified(['file.js']))
expect(match.add).toBeFalsy()
expect(match.add).toEqual([])
})

test('match added file as added', () => {
Expand All @@ -124,17 +131,20 @@ describe('matching specific change status', () => {
- added: "**/*"
`
let filter = new Filter(yaml)
const match = filter.match([{status: ChangeStatus.Added, filename: 'file.js'}])
expect(match.add).toBeTruthy()
const files = [{status: ChangeStatus.Added, filename: 'file.js'}]
const match = filter.match(files)
expect(match.add).toEqual(files)
})

test('matches when multiple statuses are configured', () => {
const yaml = `
addOrModify:
- added|modified: "**/*"
`
let filter = new Filter(yaml)
const match = filter.match([{status: ChangeStatus.Modified, filename: 'file.js'}])
expect(match.addOrModify).toBeTruthy()
const files = [{status: ChangeStatus.Modified, filename: 'file.js'}]
const match = filter.match(files)
expect(match.addOrModify).toEqual(files)
})
})

Expand Down
16 changes: 16 additions & 0 deletions __tests__/shell-escape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import shellEscape from '../src/shell-escape'

test('simple path escaped', () => {
expect(shellEscape('file')).toBe("'file'")
})

test('path with space is wrapped with single quotes', () => {
expect(shellEscape('file with space')).toBe("'file with space'")
})

test('path with quote is divided into quoted segments and escaped quote', () => {
expect(shellEscape("file'with quote")).toBe("'file'\\''with quote'")
})
test('path with leading quote does not have double quotes at beginning', () => {
expect(shellEscape("'file-leading-quote")).toBe("\\''file-leading-quote'")
})
11 changes: 8 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ inputs:
filters:
description: 'Path to the configuration file or YAML string with filters definition'
required: false
outputs:
files:
description: 'Changed files grouped by status - added, deleted or modified.'
list-files:
description: |
Enables listing of files matching the filter:
'none' - Disables listing of matching files (default).
'json' - Matching files paths are serialized as JSON array.
'shell' - Matching files paths are escaped and space-delimited. Output is usable as command line argument list in linux shell.
required: false
default: none
runs:
using: 'node12'
main: 'dist/index.js'
Expand Down
106 changes: 66 additions & 40 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4533,9 +4533,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(__webpack_require__(747));
const core = __importStar(__webpack_require__(470));
const github = __importStar(__webpack_require__(469));
const filter_1 = __importDefault(__webpack_require__(235));
const filter_1 = __webpack_require__(235);
const file_1 = __webpack_require__(258);
const git = __importStar(__webpack_require__(136));
const shell_escape_1 = __importDefault(__webpack_require__(751));
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
Expand All @@ -4546,22 +4547,21 @@ function run() {
const token = core.getInput('token', { required: false });
const filtersInput = core.getInput('filters', { required: true });
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput;
const filter = new filter_1.default(filtersYaml);
const listFiles = core.getInput('list-files', { required: false }).toLowerCase() || 'none';
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`);
return;
}
const filter = new filter_1.Filter(filtersYaml);
const files = yield getChangedFiles(token);
let results;
if (files === null) {
// Change detection was not possible
core.info('All filters will be set to true.');
results = {};
for (const key of Object.keys(filter.rules)) {
results[key] = true;
}
exportNoMatchingResults(filter);
}
else {
results = filter.match(files);
const results = filter.match(files);
exportResults(results, listFiles);
}
exportFiles(files !== null && files !== void 0 ? files : []);
exportResults(results);
}
catch (error) {
core.setFailed(error.message);
Expand Down Expand Up @@ -4669,35 +4669,42 @@ function getChangedFilesFromApi(token, pullRequest) {
return files;
});
}
function exportFiles(files) {
var _a;
const output = {};
output[file_1.ChangeStatus.Added] = [];
output[file_1.ChangeStatus.Deleted] = [];
output[file_1.ChangeStatus.Modified] = [];
for (const file of files) {
const arr = (_a = output[file.status]) !== null && _a !== void 0 ? _a : [];
arr.push(file.filename);
output[file.status] = arr;
}
core.setOutput('files', output);
// Files grouped by status
for (const [status, paths] of Object.entries(output)) {
core.startGroup(`${status.toUpperCase()} files:`);
for (const filename of paths) {
core.info(filename);
}
core.endGroup();
}
}
function exportResults(results) {
core.startGroup('Filters results:');
for (const [key, value] of Object.entries(results)) {
core.info(`${key}: ${value}`);
function exportNoMatchingResults(filter) {
core.info('All filters will be set to true but no matched files will be exported.');
for (const key of Object.keys(filter.rules)) {
core.setOutput(key, true);
}
}
function exportResults(results, format) {
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0;
core.startGroup(`Filter ${key} = ${value}`);
core.info('Matching files:');
for (const file of files) {
core.info(`${file.filename} [${file.status}]`);
}
core.setOutput(key, value);
if (format !== 'none') {
const filesValue = serializeExport(files, format);
core.setOutput(`${key}_files`, filesValue);
}
}
core.endGroup();
}
function serializeExport(files, format) {
const fileNames = files.map(file => file.filename);
switch (format) {
case 'json':
return JSON.stringify(fileNames);
case 'shell':
return fileNames.map(shell_escape_1.default).join(' ');
default:
return '';
}
}
function isExportFormat(value) {
return value === 'none' || value === 'shell' || value === 'json';
}
run();


Expand Down Expand Up @@ -4785,6 +4792,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Filter = void 0;
const jsyaml = __importStar(__webpack_require__(414));
const minimatch = __importStar(__webpack_require__(595));
// Minimatch options used in all matchers
Expand Down Expand Up @@ -4812,15 +4820,16 @@ class Filter {
this.rules[key] = this.parseFilterItemYaml(item);
}
}
// Returns dictionary with match result per rule
match(files) {
const result = {};
for (const [key, patterns] of Object.entries(this.rules)) {
const match = files.some(file => patterns.some(rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.matcher.match(file.filename)));
result[key] = match;
result[key] = files.filter(file => this.isMatch(file, patterns));
}
return result;
}
isMatch(file, patterns) {
return patterns.some(rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.matcher.match(file.filename));
}
parseFilterItemYaml(item) {
if (Array.isArray(item)) {
return flat(item.map(i => this.parseFilterItemYaml(i)));
Expand Down Expand Up @@ -4849,7 +4858,7 @@ class Filter {
throw new Error(`Invalid filter YAML format: ${message}.`);
}
}
exports.default = Filter;
exports.Filter = Filter;
// Creates a new array with all sub-array elements concatenated
// In future could be replaced by Array.prototype.flat (supported on Node.js 11+)
function flat(arr) {
Expand Down Expand Up @@ -15288,6 +15297,23 @@ function sync (path, options) {

module.exports = require("fs");

/***/ }),

/***/ 751:
/***/ (function(__unusedmodule, exports) {

"use strict";

// Credits to https://github.com/xxorax/node-shell-escape
Object.defineProperty(exports, "__esModule", { value: true });
function shellEscape(value) {
return `'${value.replace(/'/g, "'\\''")}'`
.replace(/^(?:'')+/g, '') // unduplicate single-quote at the beginning
.replace(/\\'''/g, "\\'"); // remove non-escaped single-quote if there are enclosed between 2 escaped
}
exports.default = shellEscape;


/***/ }),

/***/ 753:
Expand Down
Loading