Collect and report number of changes

This commit is contained in:
Boris Lykah 2022-02-20 17:56:44 -07:00 committed by Rebecca Turner
parent de90cc6fb3
commit 28cec18b46
No known key found for this signature in database
11 changed files with 374 additions and 169 deletions

View file

@ -1,8 +1,16 @@
export interface File {
export interface FileStatus {
filename: string
status: ChangeStatus
}
export interface FileNumstat {
filename: string
additions: number
deletions: number
}
export type File = FileStatus & FileNumstat
export enum ChangeStatus {
Added = 'added',
Copied = 'copied',

View file

@ -1,6 +1,6 @@
import * as jsyaml from 'js-yaml'
import picomatch from 'picomatch'
import {File, ChangeStatus} from './file'
import {File, ChangeStatus, FileStatus} from './file'
// Type definition of object we expect to load from YAML
interface FilterYaml {
@ -100,10 +100,19 @@ export class Filter {
for (const [key, patterns] of Object.entries(this.rules)) {
result[key] = files.filter(file => this.isMatch(file, patterns))
}
if (!this.rules.hasOwnProperty('other')) {
const matchingFilenamesList = Object.values(result).flatMap(filteredFiles =>
filteredFiles.map(file => file.filename)
)
const matchingFilenamesSet = new Set(matchingFilenamesList)
result.other = files.filter(file => !matchingFilenamesSet.has(file.filename))
}
return result
}
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
private isMatch(file: FileStatus, patterns: FilterRuleItem[]): boolean {
const aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
}

View file

@ -1,21 +1,27 @@
import {getExecOutput} from '@actions/exec'
import * as core from '@actions/core'
import {File, ChangeStatus} from './file'
import {File, ChangeStatus, FileNumstat, FileStatus} from './file'
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const HEAD = 'HEAD'
export async function getChangesInLastCommit(): Promise<File[]> {
core.startGroup(`Change detection in last commit`)
let output = ''
try {
output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
return core.group(`Change detection in last commit`, async () => {
try {
// Calling git log on the last commit works when only the last commit may be checked out. Calling git diff HEAD^..HEAD needs two commits.
const statusOutput = (
await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])
).stdout
const numstatOutput = (
await getExecOutput('git', ['log', '--format=', '--no-renames', '--numstat', '-z', '-n', '1'])
).stdout
const statusFiles = parseGitDiffNameStatusOutput(statusOutput)
const numstatFiles = parseGitDiffNumstatOutput(numstatOutput)
return mergeStatusNumstat(statusFiles, numstatFiles)
} finally {
fixStdOutNullTermination()
}
})
}
export async function getChanges(base: string, head: string): Promise<File[]> {
@ -23,32 +29,13 @@ export async function getChanges(base: string, head: string): Promise<File[]> {
const headRef = await ensureRefAvailable(head)
// Get differences between ref and HEAD
core.startGroup(`Change detection ${base}..${head}`)
let output = ''
try {
// Two dots '..' change detection - directly compares two versions
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
.stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
// Two dots '..' change detection - directly compares two versions
return core.group(`Change detection ${base}..${head}`, () => getGitDiffStatusNumstat(`${baseRef}..${headRef}`))
}
export async function getChangesOnHead(): Promise<File[]> {
// Get current changes - both staged and unstaged
core.startGroup(`Change detection on HEAD`)
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
return core.group(`Change detection on HEAD`, () => getGitDiffStatusNumstat(`HEAD`))
}
export async function getChangesSinceMergeBase(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
@ -120,21 +107,32 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
}
// Get changes introduced on ref compared to base
core.startGroup(`Change detection ${diffArg}`)
return getGitDiffStatusNumstat(diffArg)
}
async function gitDiffNameStatus(diffArg: string): Promise<string> {
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
return output
}
export function parseGitDiffOutput(output: string): File[] {
async function gitDiffNumstat(diffArg: string): Promise<string> {
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--numstat', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
}
return output
}
export function parseGitDiffNameStatusOutput(output: string): FileStatus[] {
const tokens = output.split('\u0000').filter(s => s.length > 0)
const files: File[] = []
const files: FileStatus[] = []
for (let i = 0; i + 1 < tokens.length; i += 2) {
files.push({
status: statusMap[tokens[i]],
@ -144,23 +142,45 @@ export function parseGitDiffOutput(output: string): File[] {
return files
}
export async function listAllFilesAsAdded(): Promise<File[]> {
core.startGroup('Listing all files tracked by git')
let output = ''
try {
output = (await getExecOutput('git', ['ls-files', '-z'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
function mergeStatusNumstat(statusEntries: FileStatus[], numstatEntries: FileNumstat[]): File[] {
const statusMap: {[key: string]: FileStatus} = {}
statusEntries.forEach(f => (statusMap[f.filename] = f))
return output
.split('\u0000')
.filter(s => s.length > 0)
.map(path => ({
status: ChangeStatus.Added,
filename: path
}))
return numstatEntries.map(f => {
const status = statusMap[f.filename]
if (!status) {
throw new Error(`Cannot find the status entry for file: ${f.filename}`)
}
return {...f, status: status.status}
})
}
export async function getGitDiffStatusNumstat(diffArg: string) {
const statusFiles = await gitDiffNameStatus(diffArg).then(parseGitDiffNameStatusOutput)
const numstatFiles = await gitDiffNumstat(diffArg).then(parseGitDiffNumstatOutput)
return mergeStatusNumstat(statusFiles, numstatFiles)
}
export function parseGitDiffNumstatOutput(output: string): FileNumstat[] {
const rows = output.split('\u0000').filter(s => s.length > 0)
return rows.map(row => {
const tokens = row.split('\t')
// For the binary files set the numbers to zero. This matches the response of Github API.
const additions = tokens[0] == '-' ? 0 : Number.parseInt(tokens[0])
const deletions = tokens[1] == '-' ? 0 : Number.parseInt(tokens[1])
return {
filename: tokens[2],
additions,
deletions
}
})
}
export async function listAllFilesAsAdded(): Promise<File[]> {
return core.group(`Listing all files tracked by git`, async () => {
const emptyTreeHash = (await getExecOutput('git', ['hash-object', '-t', 'tree', '/dev/null'])).stdout
return getGitDiffStatusNumstat(emptyTreeHash)
})
}
export async function getCurrentRef(): Promise<string> {

View file

@ -17,7 +17,8 @@ import * as git from './git'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
import {csvEscape} from './list-format/csv-escape'
type ExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
type FilesExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
type StatExportFormat = 'none' | 'csv' | 'json'
async function run(): Promise<void> {
try {
@ -31,12 +32,18 @@ async function run(): Promise<void> {
const base = core.getInput('base', {required: false})
const filtersInput = core.getInput('filters', {required: true})
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const listFilesFormat = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const statFormat = core.getInput('stat', {required: false}).toLowerCase() || 'none'
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
if (!isFilesExportFormat(listFilesFormat)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFilesFormat}'`)
return
}
if (!isStatExportFormat(statFormat)) {
core.setFailed(`Input parameter 'stat' is set to invalid value '${statFormat}'`)
return
}
@ -52,7 +59,7 @@ async function run(): Promise<void> {
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
core.info(`Detected ${files.length} changed files`)
const results = filter.match(files)
exportResults(results, listFiles)
exportResults(results, listFilesFormat, statFormat)
} catch (error) {
core.setFailed(getErrorMessage(error))
}
@ -204,19 +211,25 @@ async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEve
if (row.status === ChangeStatus.Renamed) {
files.push({
filename: row.filename,
status: ChangeStatus.Added
status: ChangeStatus.Added,
additions: row.additions,
deletions: row.deletions
})
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
status: ChangeStatus.Deleted,
additions: row.additions,
deletions: row.deletions
})
} else {
// Github status and git status variants are same except for deleted files
const status = row.status === 'removed' ? ChangeStatus.Deleted : (row.status as ChangeStatus)
files.push({
filename: row.filename,
status
status,
additions: row.additions,
deletions: row.deletions
})
}
}
@ -228,12 +241,20 @@ async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEve
}
}
function exportResults(results: FilterResults, format: ExportFormat): void {
interface Stat {
additionCount: number
deletionCount: number
fileCount: number
}
function exportResults(results: FilterResults, filesFormat: FilesExportFormat, statFormat: StatExportFormat): void {
core.info('Results:')
const changes = []
const changes: string[] = []
const changeStats: {[key: string]: Stat} = {}
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0
core.startGroup(`Filter ${key} = ${value}`)
const hasMatchingFiles = files.length > 0
core.startGroup(`Filter ${key} = ${hasMatchingFiles}`)
if (files.length > 0) {
changes.push(key)
core.info('Matching files:')
@ -244,12 +265,24 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
core.info('Matching files: none')
}
core.setOutput(key, value)
core.setOutput(key, hasMatchingFiles)
core.setOutput(`${key}_count`, files.length)
if (format !== 'none') {
const filesValue = serializeExport(files, format)
if (filesFormat !== 'none') {
const filesValue = serializeExportChangedFiles(files, filesFormat)
core.setOutput(`${key}_files`, filesValue)
}
const additionCount: number = files.reduce((sum, f) => sum + f.additions, 0)
const deletionCount: number = files.reduce((sum, f) => sum + f.deletions, 0)
core.setOutput(`${key}_addition_count`, additionCount)
core.setOutput(`${key}_deletion_count`, deletionCount)
core.setOutput(`${key}_change_count`, additionCount + deletionCount)
changeStats[key] = {
additionCount,
deletionCount,
fileCount: files.length
}
core.endGroup()
}
@ -260,9 +293,14 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
} else {
core.info('Cannot set changes output variable - name already used by filter output')
}
if (statFormat !== 'none') {
const statValue = serializeExportStat(changeStats, statFormat)
core.setOutput(`stat`, statValue)
}
}
function serializeExport(files: File[], format: ExportFormat): string {
function serializeExportChangedFiles(files: File[], format: FilesExportFormat): string {
const fileNames = files.map(file => file.filename)
switch (format) {
case 'csv':
@ -278,7 +316,21 @@ function serializeExport(files: File[], format: ExportFormat): string {
}
}
function isExportFormat(value: string): value is ExportFormat {
function serializeExportStat(stat: {[key: string]: Stat}, format: StatExportFormat): string {
switch (format) {
case 'csv':
return Object.keys(stat)
.sort()
.map(k => [csvEscape(k), stat[k].additionCount, stat[k].deletionCount, stat[k].fileCount].join(','))
.join('\n')
case 'json':
return JSON.stringify(stat)
default:
return ''
}
}
function isFilesExportFormat(value: string): value is FilesExportFormat {
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
}
@ -287,4 +339,8 @@ function getErrorMessage(error: unknown): string {
return String(error)
}
function isStatExportFormat(value: string): value is StatExportFormat {
return ['none', 'csv', 'json'].includes(value)
}
run()