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:
Michal Dorner 2020-07-11 17:17:56 +02:00
parent caef9bef1f
commit 1ff702da35
No known key found for this signature in database
GPG Key ID: 9EEE04B48DA36786
11 changed files with 397 additions and 90 deletions

9
.editorconfig Normal file
View 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
View File

@ -0,0 +1 @@
* text=auto eol=lf

View File

@ -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

View File

@ -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.

View File

@ -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}
})
}

View File

@ -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
View File

@ -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
View 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'
}

View File

@ -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), [])
}

View File

@ -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
}

View File

@ -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
})
}
}
}