mirror of
https://github.com/dorny/paths-filter.git
synced 2024-11-12 08:38:35 +00:00
Extend filter syntax with optional specification of file status: add, modified, deleted (#22)
* Add support for specification of change type (add,modified,delete) * Use NULL as separator in git-diff command output * Improve PR test workflow * Fix the workflow file
This commit is contained in:
parent
caef9bef1f
commit
1ff702da35
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
26
.github/workflows/pull-request-verification.yml
vendored
26
.github/workflows/pull-request-verification.yml
vendored
@ -70,3 +70,29 @@ jobs:
|
||||
- name: filter-test
|
||||
if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true'
|
||||
run: exit 1
|
||||
|
||||
test-change-type:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: touch add.txt && rm README.md && echo "TEST" > LICENSE && git add -A
|
||||
- uses: ./
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
filters: |
|
||||
add:
|
||||
- added: "add.txt"
|
||||
rm:
|
||||
- deleted: "README.md"
|
||||
modified:
|
||||
- modified: "LICENSE"
|
||||
any:
|
||||
- added|deleted|modified: "*"
|
||||
- name: filter-test
|
||||
if: |
|
||||
steps.filter.outputs.add != 'true'
|
||||
|| steps.filter.outputs.rm != 'true'
|
||||
|| steps.filter.outputs.modified != 'true'
|
||||
|| steps.filter.outputs.any != 'true'
|
||||
run: exit 1
|
||||
|
@ -20,8 +20,10 @@ Supported workflows:
|
||||
## Usage
|
||||
|
||||
Filter rules are defined using YAML format.
|
||||
Each filter rule is a list of [glob expressions](https://github.com/isaacs/minimatch).
|
||||
Corresponding output variable will be created to indicate if there's a changed file matching any of the rule glob expressions.
|
||||
Each filter has a name and set of rules.
|
||||
Rule is a [glob expressions](https://github.com/isaacs/minimatch).
|
||||
Optionally you specify if the file should be added, modified or deleted to be matched.
|
||||
For each filter there will be corresponding output variable to indicate if there's a changed file matching any of the rules.
|
||||
Output variables can be later used in the `if` clause to conditionally run specific steps.
|
||||
|
||||
### Inputs
|
||||
@ -30,7 +32,7 @@ Output variables can be later used in the `if` clause to conditionally run speci
|
||||
- **`base`**: Git reference (e.g. branch name) against which the changes will be detected. Defaults to repository default branch (e.g. master).
|
||||
If it references same branch it was pushed to, changes are detected against the most recent commit before the push.
|
||||
This option is ignored if action is triggered by *pull_request* event.
|
||||
- **`filters`**: Path to the configuration file or directly embedded string in YAML format. Filter configuration is a dictionary, where keys specifies rule names and values are lists of file path patterns.
|
||||
- **`filters`**: Path to the configuration file or directly embedded string in YAML format.
|
||||
|
||||
### Outputs
|
||||
- For each rule it sets output variable named by the rule to text:
|
||||
@ -41,6 +43,7 @@ Output variables can be later used in the `if` clause to conditionally run speci
|
||||
- minimatch [dot](https://www.npmjs.com/package/minimatch#dot) option is set to true - therefore
|
||||
globbing will match also paths where file or folder name starts with a dot.
|
||||
- You can use YAML anchors to reuse path expression(s) inside another rule. See example in the tests.
|
||||
- It's recommended to put quote your path expressions with `'` or `"`. Otherwise you will get an error if it starts with `*`.
|
||||
- If changes are detected against the previous commit and there is none (i.e. first push of a new branch), all filter rules will report changed files.
|
||||
- You can use `base: ${{ github.ref }}` to configure change detection against previous commit for every branch you create.
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import Filter from '../src/filter'
|
||||
import {File, ChangeStatus} from '../src/file'
|
||||
|
||||
describe('yaml filter parsing tests', () => {
|
||||
test('throws if yaml is not a dictionary', () => {
|
||||
@ -6,14 +7,6 @@ describe('yaml filter parsing tests', () => {
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
test('throws on invalid yaml', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
src/**/*.js
|
||||
`
|
||||
const t = () => new Filter(yaml)
|
||||
expect(t).toThrow(/^Invalid filter.*/)
|
||||
})
|
||||
test('throws if pattern is not a string', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
@ -27,13 +20,21 @@ describe('yaml filter parsing tests', () => {
|
||||
})
|
||||
|
||||
describe('matching tests', () => {
|
||||
test('matches single inline rule', () => {
|
||||
const yaml = `
|
||||
src: "src/**/*.js"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const match = filter.match(modified(['src/app/module/file.js']))
|
||||
expect(match.src).toBeTruthy()
|
||||
})
|
||||
test('matches single rule in single group', () => {
|
||||
const yaml = `
|
||||
src:
|
||||
- src/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['src/app/module/file.js'])
|
||||
const match = filter.match(modified(['src/app/module/file.js']))
|
||||
expect(match.src).toBeTruthy()
|
||||
})
|
||||
|
||||
@ -43,7 +44,7 @@ describe('matching tests', () => {
|
||||
- src/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['not_src/other_file.js'])
|
||||
const match = filter.match(modified(['not_src/other_file.js']))
|
||||
expect(match.src).toBeFalsy()
|
||||
})
|
||||
|
||||
@ -55,7 +56,7 @@ describe('matching tests', () => {
|
||||
- test/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['test/test.js'])
|
||||
const match = filter.match(modified(['test/test.js']))
|
||||
expect(match.src).toBeFalsy()
|
||||
expect(match.test).toBeTruthy()
|
||||
})
|
||||
@ -67,7 +68,7 @@ describe('matching tests', () => {
|
||||
- test/**/*.js
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['test/test.js'])
|
||||
const match = filter.match(modified(['test/test.js']))
|
||||
expect(match.src).toBeTruthy()
|
||||
})
|
||||
|
||||
@ -77,7 +78,7 @@ describe('matching tests', () => {
|
||||
- "**/*"
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['test/test.js'])
|
||||
const match = filter.match(modified(['test/test.js']))
|
||||
expect(match.any).toBeTruthy()
|
||||
})
|
||||
|
||||
@ -87,7 +88,7 @@ describe('matching tests', () => {
|
||||
- "**/*.js"
|
||||
`
|
||||
const filter = new Filter(yaml)
|
||||
const match = filter.match(['.test/.test.js'])
|
||||
const match = filter.match(modified(['.test/.test.js']))
|
||||
expect(match.dot).toBeTruthy()
|
||||
})
|
||||
|
||||
@ -101,7 +102,44 @@ describe('matching tests', () => {
|
||||
- src/**/*
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const match = filter.match(['config/settings.yml'])
|
||||
const match = filter.match(modified(['config/settings.yml']))
|
||||
expect(match.src).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matching specific change status', () => {
|
||||
test('does not match modified file as added', () => {
|
||||
const yaml = `
|
||||
add:
|
||||
- added: "**/*"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const match = filter.match(modified(['file.js']))
|
||||
expect(match.add).toBeFalsy()
|
||||
})
|
||||
|
||||
test('match added file as added', () => {
|
||||
const yaml = `
|
||||
add:
|
||||
- added: "**/*"
|
||||
`
|
||||
let filter = new Filter(yaml)
|
||||
const match = filter.match([{status: ChangeStatus.Added, filename: 'file.js'}])
|
||||
expect(match.add).toBeTruthy()
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
function modified(paths: string[]): File[] {
|
||||
return paths.map(filename => {
|
||||
return {filename, status: ChangeStatus.Modified}
|
||||
})
|
||||
}
|
||||
|
@ -1,4 +1,27 @@
|
||||
import * as git from '../src/git'
|
||||
import {ExecOptions} from '@actions/exec'
|
||||
import {ChangeStatus} from '../src/file'
|
||||
|
||||
describe('parsing of the git diff-index command', () => {
|
||||
test('getChangedFiles returns files with correct change status', async () => {
|
||||
const files = await git.getChangedFiles(git.FETCH_HEAD, (cmd, args, opts) => {
|
||||
const stdout = opts?.listeners?.stdout
|
||||
if (stdout) {
|
||||
stdout(Buffer.from('A\u0000LICENSE\u0000'))
|
||||
stdout(Buffer.from('M\u0000src/index.ts\u0000'))
|
||||
stdout(Buffer.from('D\u0000src/main.ts\u0000'))
|
||||
}
|
||||
return Promise.resolve(0)
|
||||
})
|
||||
expect(files.length).toBe(3)
|
||||
expect(files[0].filename).toBe('LICENSE')
|
||||
expect(files[0].status).toBe(ChangeStatus.Added)
|
||||
expect(files[1].filename).toBe('src/index.ts')
|
||||
expect(files[1].status).toBe(ChangeStatus.Modified)
|
||||
expect(files[2].filename).toBe('src/main.ts')
|
||||
expect(files[2].status).toBe(ChangeStatus.Deleted)
|
||||
})
|
||||
})
|
||||
|
||||
describe('git utility function tests (those not invoking git)', () => {
|
||||
test('Detects if ref references a tag', () => {
|
||||
|
164
dist/index.js
vendored
164
dist/index.js
vendored
@ -3788,6 +3788,25 @@ module.exports = require("child_process");
|
||||
|
||||
"use strict";
|
||||
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
@ -3800,6 +3819,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.trimRefsHeads = exports.trimRefs = exports.isTagRef = exports.getChangedFiles = exports.fetchCommit = exports.FETCH_HEAD = exports.NULL_SHA = void 0;
|
||||
const exec_1 = __webpack_require__(986);
|
||||
const core = __importStar(__webpack_require__(470));
|
||||
const file_1 = __webpack_require__(258);
|
||||
exports.NULL_SHA = '0000000000000000000000000000000000000000';
|
||||
exports.FETCH_HEAD = 'FETCH_HEAD';
|
||||
function fetchCommit(ref) {
|
||||
@ -3811,10 +3832,10 @@ function fetchCommit(ref) {
|
||||
});
|
||||
}
|
||||
exports.fetchCommit = fetchCommit;
|
||||
function getChangedFiles(ref) {
|
||||
function getChangedFiles(ref, cmd = exec_1.exec) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
let output = '';
|
||||
const exitCode = yield exec_1.exec('git', ['diff-index', '--name-only', ref], {
|
||||
const exitCode = yield cmd('git', ['diff-index', '--name-status', '-z', ref], {
|
||||
listeners: {
|
||||
stdout: (data) => (output += data.toString())
|
||||
}
|
||||
@ -3822,10 +3843,19 @@ function getChangedFiles(ref) {
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Couldn't determine changed files`);
|
||||
}
|
||||
return output
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
// Previous command uses NULL as delimiters and output is printed to stdout.
|
||||
// We have to make sure next thing written to stdout will start on new line.
|
||||
// Otherwise things like ::set-output wouldn't work.
|
||||
core.info('');
|
||||
const tokens = output.split('\u0000').filter(s => s.length > 0);
|
||||
const files = [];
|
||||
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
||||
files.push({
|
||||
status: statusMap[tokens[i]],
|
||||
filename: tokens[i + 1]
|
||||
});
|
||||
}
|
||||
return files;
|
||||
});
|
||||
}
|
||||
exports.getChangedFiles = getChangedFiles;
|
||||
@ -3845,6 +3875,14 @@ exports.trimRefsHeads = trimRefsHeads;
|
||||
function trimStart(ref, start) {
|
||||
return ref.startsWith(start) ? ref.substr(start.length) : ref;
|
||||
}
|
||||
const statusMap = {
|
||||
A: file_1.ChangeStatus.Added,
|
||||
C: file_1.ChangeStatus.Copied,
|
||||
D: file_1.ChangeStatus.Deleted,
|
||||
M: file_1.ChangeStatus.Modified,
|
||||
R: file_1.ChangeStatus.Renamed,
|
||||
U: file_1.ChangeStatus.Unmerged
|
||||
};
|
||||
|
||||
|
||||
/***/ }),
|
||||
@ -4496,6 +4534,7 @@ 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 file_1 = __webpack_require__(258);
|
||||
const git = __importStar(__webpack_require__(136));
|
||||
function run() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
@ -4599,7 +4638,26 @@ function getChangedFilesFromApi(token, pullRequest) {
|
||||
per_page: pageSize
|
||||
});
|
||||
for (const row of response.data) {
|
||||
files.push(row.filename);
|
||||
// There's no obvious use-case for detection of renames
|
||||
// Therefore we treat it as if rename detection in git diff was turned off.
|
||||
// Rename is replaced by delete of original filename and add of new filename
|
||||
if (row.status === file_1.ChangeStatus.Renamed) {
|
||||
files.push({
|
||||
filename: row.filename,
|
||||
status: file_1.ChangeStatus.Added
|
||||
});
|
||||
files.push({
|
||||
// 'previous_filename' for some unknown reason isn't in the type definition or documentation
|
||||
filename: row.previous_filename,
|
||||
status: file_1.ChangeStatus.Deleted
|
||||
});
|
||||
}
|
||||
else {
|
||||
files.push({
|
||||
filename: row.filename,
|
||||
status: row.status
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
@ -4694,49 +4752,93 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const jsyaml = __importStar(__webpack_require__(414));
|
||||
const minimatch = __importStar(__webpack_require__(595));
|
||||
// Minimatch options used in all matchers
|
||||
const MinimatchOptions = {
|
||||
dot: true
|
||||
};
|
||||
class Filter {
|
||||
// Creates instance of Filter and load rules from YAML if it's provided
|
||||
constructor(yaml) {
|
||||
this.rules = {};
|
||||
const doc = jsyaml.safeLoad(yaml);
|
||||
if (typeof doc !== 'object') {
|
||||
this.throwInvalidFormatError();
|
||||
}
|
||||
const opts = {
|
||||
dot: true
|
||||
};
|
||||
for (const name of Object.keys(doc)) {
|
||||
const patternsNode = doc[name];
|
||||
if (!Array.isArray(patternsNode)) {
|
||||
this.throwInvalidFormatError();
|
||||
}
|
||||
const patterns = flat(patternsNode);
|
||||
if (!patterns.every(x => typeof x === 'string')) {
|
||||
this.throwInvalidFormatError();
|
||||
}
|
||||
this.rules[name] = patterns.map(x => new minimatch.Minimatch(x, opts));
|
||||
if (yaml) {
|
||||
this.load(yaml);
|
||||
}
|
||||
}
|
||||
// Returns dictionary with match result per rules group
|
||||
match(paths) {
|
||||
// Load rules from YAML string
|
||||
load(yaml) {
|
||||
const doc = jsyaml.safeLoad(yaml);
|
||||
if (typeof doc !== 'object') {
|
||||
this.throwInvalidFormatError('Root element is not an object');
|
||||
}
|
||||
for (const [key, item] of Object.entries(doc)) {
|
||||
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 = paths.some(fileName => patterns.some(rule => rule.match(fileName)));
|
||||
const match = files.some(file => patterns.some(rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.matcher.match(file.filename)));
|
||||
result[key] = match;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
throwInvalidFormatError() {
|
||||
throw new Error('Invalid filter YAML format: Expected dictionary of string arrays');
|
||||
parseFilterItemYaml(item) {
|
||||
if (Array.isArray(item)) {
|
||||
return flat(item.map(i => this.parseFilterItemYaml(i)));
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
return [{ status: undefined, matcher: new minimatch.Minimatch(item, MinimatchOptions) }];
|
||||
}
|
||||
if (typeof item === 'object') {
|
||||
return Object.entries(item).map(([key, pattern]) => {
|
||||
if (typeof key !== 'string' || typeof pattern !== 'string') {
|
||||
this.throwInvalidFormatError(`Expected [key:string]= pattern:string, but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`);
|
||||
}
|
||||
return {
|
||||
status: key
|
||||
.split('|')
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.length > 0)
|
||||
.map(x => x.toLowerCase()),
|
||||
matcher: new minimatch.Minimatch(pattern, MinimatchOptions)
|
||||
};
|
||||
});
|
||||
}
|
||||
this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`);
|
||||
}
|
||||
throwInvalidFormatError(message) {
|
||||
throw new Error(`Invalid filter YAML format: ${message}.`);
|
||||
}
|
||||
}
|
||||
exports.default = Filter;
|
||||
// Creates a new array with all sub-array elements recursively concatenated
|
||||
// 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) {
|
||||
return arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? flat(val) : val), []);
|
||||
return arr.reduce((acc, val) => acc.concat(val), []);
|
||||
}
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 258:
|
||||
/***/ (function(__unusedmodule, exports) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChangeStatus = void 0;
|
||||
var ChangeStatus;
|
||||
(function (ChangeStatus) {
|
||||
ChangeStatus["Added"] = "added";
|
||||
ChangeStatus["Copied"] = "copied";
|
||||
ChangeStatus["Deleted"] = "deleted";
|
||||
ChangeStatus["Modified"] = "modified";
|
||||
ChangeStatus["Renamed"] = "renamed";
|
||||
ChangeStatus["Unmerged"] = "unmerged";
|
||||
})(ChangeStatus = exports.ChangeStatus || (exports.ChangeStatus = {}));
|
||||
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ 260:
|
||||
|
13
src/file.ts
Normal file
13
src/file.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export interface File {
|
||||
filename: string
|
||||
status: ChangeStatus
|
||||
}
|
||||
|
||||
export enum ChangeStatus {
|
||||
Added = 'added',
|
||||
Copied = 'copied',
|
||||
Deleted = 'deleted',
|
||||
Modified = 'modified',
|
||||
Renamed = 'renamed',
|
||||
Unmerged = 'unmerged'
|
||||
}
|
110
src/filter.ts
110
src/filter.ts
@ -1,49 +1,101 @@
|
||||
import * as jsyaml from 'js-yaml'
|
||||
import * as minimatch from 'minimatch'
|
||||
import {File, ChangeStatus} from './file'
|
||||
|
||||
// Type definition of object we expect to load from YAML
|
||||
interface FilterYaml {
|
||||
[name: string]: FilterItemYaml
|
||||
}
|
||||
type FilterItemYaml =
|
||||
| string // Filename pattern, e.g. "path/to/*.js"
|
||||
| {[changeTypes: string]: string} // Change status and filename, e.g. added|modified: "path/to/*.js"
|
||||
| FilterItemYaml[] // Supports referencing another rule via YAML anchor
|
||||
|
||||
// Minimatch options used in all matchers
|
||||
const MinimatchOptions: minimatch.IOptions = {
|
||||
dot: true
|
||||
}
|
||||
|
||||
// Internal representation of one item in named filter rule
|
||||
// Created as simplified form of data in FilterItemYaml
|
||||
interface FilterRuleItem {
|
||||
status?: ChangeStatus[] // Required change status of the matched files
|
||||
matcher: minimatch.IMinimatch // Matches the filename
|
||||
}
|
||||
|
||||
export default class Filter {
|
||||
rules: {[key: string]: minimatch.IMinimatch[]} = {}
|
||||
rules: {[key: string]: FilterRuleItem[]} = {}
|
||||
|
||||
constructor(yaml: string) {
|
||||
const doc = jsyaml.safeLoad(yaml)
|
||||
if (typeof doc !== 'object') {
|
||||
this.throwInvalidFormatError()
|
||||
}
|
||||
|
||||
const opts: minimatch.IOptions = {
|
||||
dot: true
|
||||
}
|
||||
|
||||
for (const name of Object.keys(doc)) {
|
||||
const patternsNode = doc[name]
|
||||
if (!Array.isArray(patternsNode)) {
|
||||
this.throwInvalidFormatError()
|
||||
}
|
||||
const patterns = flat(patternsNode) as string[]
|
||||
if (!patterns.every(x => typeof x === 'string')) {
|
||||
this.throwInvalidFormatError()
|
||||
}
|
||||
this.rules[name] = patterns.map(x => new minimatch.Minimatch(x, opts))
|
||||
// Creates instance of Filter and load rules from YAML if it's provided
|
||||
constructor(yaml?: string) {
|
||||
if (yaml) {
|
||||
this.load(yaml)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns dictionary with match result per rules group
|
||||
match(paths: string[]): {[key: string]: boolean} {
|
||||
// Load rules from YAML string
|
||||
load(yaml: string): void {
|
||||
const doc = jsyaml.safeLoad(yaml) as FilterYaml
|
||||
if (typeof doc !== 'object') {
|
||||
this.throwInvalidFormatError('Root element is not an object')
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(doc)) {
|
||||
this.rules[key] = this.parseFilterItemYaml(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns dictionary with match result per rule
|
||||
match(files: File[]): {[key: string]: boolean} {
|
||||
const result: {[key: string]: boolean} = {}
|
||||
for (const [key, patterns] of Object.entries(this.rules)) {
|
||||
const match = paths.some(fileName => patterns.some(rule => rule.match(fileName)))
|
||||
const match = files.some(file =>
|
||||
patterns.some(
|
||||
rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.matcher.match(file.filename)
|
||||
)
|
||||
)
|
||||
result[key] = match
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private throwInvalidFormatError(): never {
|
||||
throw new Error('Invalid filter YAML format: Expected dictionary of string arrays')
|
||||
private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {
|
||||
if (Array.isArray(item)) {
|
||||
return flat(item.map(i => this.parseFilterItemYaml(i)))
|
||||
}
|
||||
|
||||
if (typeof item === 'string') {
|
||||
return [{status: undefined, matcher: new minimatch.Minimatch(item, MinimatchOptions)}]
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
return Object.entries(item).map(([key, pattern]) => {
|
||||
if (typeof key !== 'string' || typeof pattern !== 'string') {
|
||||
this.throwInvalidFormatError(
|
||||
`Expected [key:string]= pattern:string, but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`
|
||||
)
|
||||
}
|
||||
return {
|
||||
status: key
|
||||
.split('|')
|
||||
.map(x => x.trim())
|
||||
.filter(x => x.length > 0)
|
||||
.map(x => x.toLowerCase()) as ChangeStatus[],
|
||||
matcher: new minimatch.Minimatch(pattern, MinimatchOptions)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`)
|
||||
}
|
||||
|
||||
private throwInvalidFormatError(message: string): never {
|
||||
throw new Error(`Invalid filter YAML format: ${message}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a new array with all sub-array elements recursively concatenated
|
||||
// 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: any[]): any[] {
|
||||
return arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? flat(val) : val), [])
|
||||
function flat<T>(arr: T[][]): T[] {
|
||||
return arr.reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
|
33
src/git.ts
33
src/git.ts
@ -1,4 +1,6 @@
|
||||
import {exec} from '@actions/exec'
|
||||
import * as core from '@actions/core'
|
||||
import {File, ChangeStatus} from './file'
|
||||
|
||||
export const NULL_SHA = '0000000000000000000000000000000000000000'
|
||||
export const FETCH_HEAD = 'FETCH_HEAD'
|
||||
@ -10,9 +12,9 @@ export async function fetchCommit(ref: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChangedFiles(ref: string): Promise<string[]> {
|
||||
export async function getChangedFiles(ref: string, cmd = exec): Promise<File[]> {
|
||||
let output = ''
|
||||
const exitCode = await exec('git', ['diff-index', '--name-only', ref], {
|
||||
const exitCode = await cmd('git', ['diff-index', '--name-status', '-z', ref], {
|
||||
listeners: {
|
||||
stdout: (data: Buffer) => (output += data.toString())
|
||||
}
|
||||
@ -22,10 +24,20 @@ export async function getChangedFiles(ref: string): Promise<string[]> {
|
||||
throw new Error(`Couldn't determine changed files`)
|
||||
}
|
||||
|
||||
return output
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
// Previous command uses NULL as delimiters and output is printed to stdout.
|
||||
// We have to make sure next thing written to stdout will start on new line.
|
||||
// Otherwise things like ::set-output wouldn't work.
|
||||
core.info('')
|
||||
|
||||
const tokens = output.split('\u0000').filter(s => s.length > 0)
|
||||
const files: File[] = []
|
||||
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
||||
files.push({
|
||||
status: statusMap[tokens[i]],
|
||||
filename: tokens[i + 1]
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export function isTagRef(ref: string): boolean {
|
||||
@ -44,3 +56,12 @@ export function trimRefsHeads(ref: string): string {
|
||||
function trimStart(ref: string, start: string): string {
|
||||
return ref.startsWith(start) ? ref.substr(start.length) : ref
|
||||
}
|
||||
|
||||
const statusMap: {[char: string]: ChangeStatus} = {
|
||||
A: ChangeStatus.Added,
|
||||
C: ChangeStatus.Copied,
|
||||
D: ChangeStatus.Deleted,
|
||||
M: ChangeStatus.Modified,
|
||||
R: ChangeStatus.Renamed,
|
||||
U: ChangeStatus.Unmerged
|
||||
}
|
||||
|
31
src/main.ts
31
src/main.ts
@ -4,6 +4,7 @@ import * as github from '@actions/github'
|
||||
import {Webhooks} from '@octokit/webhooks'
|
||||
|
||||
import Filter from './filter'
|
||||
import {File, ChangeStatus} from './file'
|
||||
import * as git from './git'
|
||||
|
||||
async function run(): Promise<void> {
|
||||
@ -53,7 +54,7 @@ function getConfigFileContent(configPath: string): string {
|
||||
return fs.readFileSync(configPath, {encoding: 'utf8'})
|
||||
}
|
||||
|
||||
async function getChangedFiles(token: string): Promise<string[] | null> {
|
||||
async function getChangedFiles(token: string): Promise<File[] | null> {
|
||||
if (github.context.eventName === 'pull_request') {
|
||||
const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest
|
||||
return token ? await getChangedFilesFromApi(token, pr) : await getChangedFilesFromGit(pr.base.sha)
|
||||
@ -64,7 +65,7 @@ async function getChangedFiles(token: string): Promise<string[] | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getChangedFilesFromPush(): Promise<string[] | null> {
|
||||
async function getChangedFilesFromPush(): Promise<File[] | null> {
|
||||
const push = github.context.payload as Webhooks.WebhookPayloadPush
|
||||
|
||||
// No change detection for pushed tags
|
||||
@ -86,7 +87,7 @@ async function getChangedFilesFromPush(): Promise<string[] | null> {
|
||||
}
|
||||
|
||||
// Fetch base branch and use `git diff` to determine changed files
|
||||
async function getChangedFilesFromGit(ref: string): Promise<string[]> {
|
||||
async function getChangedFilesFromGit(ref: string): Promise<File[]> {
|
||||
core.debug('Fetching base branch and using `git diff-index` to determine changed files')
|
||||
await git.fetchCommit(ref)
|
||||
// FETCH_HEAD will always point to the just fetched commit
|
||||
@ -98,11 +99,11 @@ async function getChangedFilesFromGit(ref: string): Promise<string[]> {
|
||||
async function getChangedFilesFromApi(
|
||||
token: string,
|
||||
pullRequest: Webhooks.WebhookPayloadPullRequestPullRequest
|
||||
): Promise<string[]> {
|
||||
): Promise<File[]> {
|
||||
core.debug('Fetching list of modified files from Github API')
|
||||
const client = new github.GitHub(token)
|
||||
const pageSize = 100
|
||||
const files: string[] = []
|
||||
const files: File[] = []
|
||||
for (let page = 0; page * pageSize < pullRequest.changed_files; page++) {
|
||||
const response = await client.pulls.listFiles({
|
||||
owner: github.context.repo.owner,
|
||||
@ -112,7 +113,25 @@ async function getChangedFilesFromApi(
|
||||
per_page: pageSize
|
||||
})
|
||||
for (const row of response.data) {
|
||||
files.push(row.filename)
|
||||
// There's no obvious use-case for detection of renames
|
||||
// Therefore we treat it as if rename detection in git diff was turned off.
|
||||
// Rename is replaced by delete of original filename and add of new filename
|
||||
if (row.status === ChangeStatus.Renamed) {
|
||||
files.push({
|
||||
filename: row.filename,
|
||||
status: ChangeStatus.Added
|
||||
})
|
||||
files.push({
|
||||
// 'previous_filename' for some unknown reason isn't in the type definition or documentation
|
||||
filename: (<any>row).previous_filename as string,
|
||||
status: ChangeStatus.Deleted
|
||||
})
|
||||
} else {
|
||||
files.push({
|
||||
filename: row.filename,
|
||||
status: row.status as ChangeStatus
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user