From 1ff702da352ee14ab3e5a8804f9d6348c9d5d920 Mon Sep 17 00:00:00 2001 From: Michal Dorner Date: Sat, 11 Jul 2020 17:17:56 +0200 Subject: [PATCH] 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 --- .editorconfig | 9 + .gitattributes | 1 + .../workflows/pull-request-verification.yml | 26 +++ README.md | 9 +- __tests__/filter.test.ts | 68 ++++++-- __tests__/git.test.ts | 23 +++ dist/index.js | 164 ++++++++++++++---- src/file.ts | 13 ++ src/filter.ts | 110 ++++++++---- src/git.ts | 33 +++- src/main.ts | 31 +++- 11 files changed, 397 insertions(+), 90 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 src/file.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..79621be --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/workflows/pull-request-verification.yml b/.github/workflows/pull-request-verification.yml index 6a29ee6..46ba5ff 100644 --- a/.github/workflows/pull-request-verification.yml +++ b/.github/workflows/pull-request-verification.yml @@ -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 diff --git a/README.md b/README.md index 6f60266..d1e1b29 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/__tests__/filter.test.ts b/__tests__/filter.test.ts index b9b7c44..15bb9f3 100644 --- a/__tests__/filter.test.ts +++ b/__tests__/filter.test.ts @@ -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} + }) +} diff --git a/__tests__/git.test.ts b/__tests__/git.test.ts index b5c25b0..f31b374 100644 --- a/__tests__/git.test.ts +++ b/__tests__/git.test.ts @@ -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', () => { diff --git a/dist/index.js b/dist/index.js index da21598..842fa33 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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: diff --git a/src/file.ts b/src/file.ts new file mode 100644 index 0000000..d8125a7 --- /dev/null +++ b/src/file.ts @@ -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' +} diff --git a/src/filter.ts b/src/filter.ts index 1f81d75..f951749 100644 --- a/src/filter.ts +++ b/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(arr: T[][]): T[] { + return arr.reduce((acc, val) => acc.concat(val), []) } diff --git a/src/git.ts b/src/git.ts index 25829e1..b9695d5 100644 --- a/src/git.ts +++ b/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 { } } -export async function getChangedFiles(ref: string): Promise { +export async function getChangedFiles(ref: string, cmd = exec): Promise { 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 { 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 +} diff --git a/src/main.ts b/src/main.ts index 9ffed55..0f2c878 100644 --- a/src/main.ts +++ b/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 { @@ -53,7 +54,7 @@ function getConfigFileContent(configPath: string): string { return fs.readFileSync(configPath, {encoding: 'utf8'}) } -async function getChangedFiles(token: string): Promise { +async function getChangedFiles(token: string): Promise { 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 { } } -async function getChangedFilesFromPush(): Promise { +async function getChangedFilesFromPush(): Promise { const push = github.context.payload as Webhooks.WebhookPayloadPush // No change detection for pushed tags @@ -86,7 +87,7 @@ async function getChangedFilesFromPush(): Promise { } // Fetch base branch and use `git diff` to determine changed files -async function getChangedFilesFromGit(ref: string): Promise { +async function getChangedFilesFromGit(ref: string): Promise { 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 { async function getChangedFilesFromApi( token: string, pullRequest: Webhooks.WebhookPayloadPullRequestPullRequest -): Promise { +): Promise { 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: (row).previous_filename as string, + status: ChangeStatus.Deleted + }) + } else { + files.push({ + filename: row.filename, + status: row.status as ChangeStatus + }) + } } }