generated from actions/typescript-action
-
Notifications
You must be signed in to change notification settings - Fork 248
/
main.ts
290 lines (255 loc) · 10.9 KB
/
main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import * as fs from 'fs'
import * as core from '@actions/core'
import * as github from '@actions/github'
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'
import {
isPredicateQuantifier,
Filter,
FilterConfig,
FilterResults,
PredicateQuantifier,
SUPPORTED_PREDICATE_QUANTIFIERS
} from './filter'
import {File, ChangeStatus} from './file'
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'
async function run(): Promise<void> {
try {
const workingDirectory = core.getInput('working-directory', {required: false})
if (workingDirectory) {
process.chdir(workingDirectory)
}
const token = core.getInput('token', {required: false})
const ref = core.getInput('ref', {required: false})
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 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}'`)
return
}
if (!isPredicateQuantifier(predicateQuantifier)) {
const predicateQuantifierInvalidErrorMsg =
`Input parameter 'predicate-quantifier' is set to invalid value ` +
`'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`
throw new Error(predicateQuantifierInvalidErrorMsg)
}
const filterConfig: FilterConfig = {predicateQuantifier}
const filter = new Filter(filtersYaml, filterConfig)
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
core.info(`Detected ${files.length} changed files`)
const results = filter.match(files)
exportResults(results, listFiles)
} catch (error) {
core.setFailed(getErrorMessage(error))
}
}
function isPathInput(text: string): boolean {
return !(text.includes('\n') || text.includes(':'))
}
function getConfigFileContent(configPath: string): string {
if (!fs.existsSync(configPath)) {
throw new Error(`Configuration file '${configPath}' not found`)
}
if (!fs.lstatSync(configPath).isFile()) {
throw new Error(`'${configPath}' is not a file.`)
}
return fs.readFileSync(configPath, {encoding: 'utf8'})
}
async function getChangedFiles(token: string, base: string, ref: string, initialFetchDepth: number): Promise<File[]> {
// if base is 'HEAD' only local uncommitted changes will be detected
// This is the simplest case as we don't need to fetch more commits or evaluate current/before refs
if (base === git.HEAD) {
if (ref) {
core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
}
return await git.getChangesOnHead()
}
const prEvents = ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target']
if (prEvents.includes(github.context.eventName)) {
if (ref) {
core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`)
}
if (base) {
core.warning(`'base' input parameter is ignored when action is triggered by pull request event`)
}
const pr = github.context.payload.pull_request as PullRequestEvent
if (token) {
return await getChangedFilesFromApi(token, pr)
}
if (github.context.eventName === 'pull_request_target') {
// pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch
// Therefor it's not possible to look at changes in last commit
// At the same time we don't want to fetch any code from forked repository
throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`)
}
core.info('Github token is not available - changes will be detected using git diff')
const baseSha = github.context.payload.pull_request?.base.sha
const defaultBranch = github.context.payload.repository?.default_branch
const currentRef = await git.getCurrentRef()
return await git.getChanges(base || baseSha || defaultBranch, currentRef)
} else {
return getChangedFilesFromGit(base, ref, initialFetchDepth)
}
}
async function getChangedFilesFromGit(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
const defaultBranch = github.context.payload.repository?.default_branch
const beforeSha = github.context.eventName === 'push' ? (github.context.payload as PushEvent).before : null
const currentRef = await git.getCurrentRef()
head = git.getShortName(head || github.context.ref || currentRef)
base = git.getShortName(base || defaultBranch)
if (!head) {
throw new Error(
"This action requires 'head' input to be configured, 'ref' to be set in the event payload or branch/tag checked out in current git repository"
)
}
if (!base) {
throw new Error(
"This action requires 'base' input to be configured or 'repository.default_branch' to be set in the event payload"
)
}
const isBaseSha = git.isGitSha(base)
const isBaseSameAsHead = base === head
// If base is commit SHA we will do comparison against the referenced commit
// Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit
if (isBaseSha || isBaseSameAsHead) {
const baseSha = isBaseSha ? base : beforeSha
if (!baseSha) {
core.warning(`'before' field is missing in event payload - changes will be detected from last commit`)
if (head !== currentRef) {
core.warning(`Ref ${head} is not checked out - results might be incorrect!`)
}
return await git.getChangesInLastCommit()
}
// If there is no previously pushed commit,
// we will do comparison against the default branch or return all as added
if (baseSha === git.NULL_SHA) {
if (defaultBranch && base !== defaultBranch) {
core.info(
`First push of a branch detected - changes will be detected against the default branch ${defaultBranch}`
)
return await git.getChangesSinceMergeBase(defaultBranch, head, initialFetchDepth)
} else {
core.info('Initial push detected - all files will be listed as added')
if (head !== currentRef) {
core.warning(`Ref ${head} is not checked out - results might be incorrect!`)
}
return await git.listAllFilesAsAdded()
}
}
core.info(`Changes will be detected between ${baseSha} and ${head}`)
return await git.getChanges(baseSha, head)
}
core.info(`Changes will be detected between ${base} and ${head}`)
return await git.getChangesSinceMergeBase(base, head, initialFetchDepth)
}
// Uses github REST api to get list of files changed in PR
async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEvent): Promise<File[]> {
core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from Github API`)
try {
const client = github.getOctokit(token)
const per_page = 100
const files: File[] = []
core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, per_page: ${per_page})`)
for await (const response of client.paginate.iterator(
client.rest.pulls.listFiles.endpoint.merge({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: pullRequest.number,
per_page
})
)) {
if (response.status !== 200) {
throw new Error(`Fetching list of changed files from GitHub API failed with error code ${response.status}`)
}
core.info(`Received ${response.data.length} items`)
for (const row of response.data as GetResponseDataTypeFromEndpointMethod<typeof client.rest.pulls.listFiles>) {
core.info(`[${row.status}] ${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 {
// 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
})
}
}
}
return files
} finally {
core.endGroup()
}
}
function exportResults(results: FilterResults, format: ExportFormat): void {
core.info('Results:')
const changes = []
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0
core.startGroup(`Filter ${key} = ${value}`)
if (files.length > 0) {
changes.push(key)
core.info('Matching files:')
for (const file of files) {
core.info(`${file.filename} [${file.status}]`)
}
} else {
core.info('Matching files: none')
}
core.setOutput(key, value)
core.setOutput(`${key}_count`, files.length)
if (format !== 'none') {
const filesValue = serializeExport(files, format)
core.setOutput(`${key}_files`, filesValue)
}
core.endGroup()
}
if (results['changes'] === undefined) {
const changesJson = JSON.stringify(changes)
core.info(`Changes output set to ${changesJson}`)
core.setOutput('changes', changesJson)
} else {
core.info('Cannot set changes output variable - name already used by filter output')
}
}
function serializeExport(files: File[], format: ExportFormat): string {
const fileNames = files.map(file => file.filename)
switch (format) {
case 'csv':
return fileNames.map(csvEscape).join(',')
case 'json':
return JSON.stringify(fileNames)
case 'escape':
return fileNames.map(backslashEscape).join(' ')
case 'shell':
return fileNames.map(shellEscape).join(' ')
default:
return ''
}
}
function isExportFormat(value: string): value is ExportFormat {
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
run()