-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
421 lines (344 loc) · 14.2 KB
/
index.js
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
const core = require('@actions/core')
const github = require('@actions/github')
let columns_labels = core.getInput('columns_labels')
const token = core.getInput('token')
// Javascript destructuring assignment. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment
const {owner, repo} = github.context.repo
const octokit = github.getOctokit(token)
const MAX_CARDS_PER_PAGE = 100 // from https://docs.github.com/en/rest/reference/projects#list-project-cards
// Determines if an object is an object
// @param {any} variable The object to check
// @returns {boolean} true if variable is an object, false otherwise
function isObject (variable) {
return typeof variable === 'object' && !Array.isArray(variable) && variable !== null
}
// Determines if an object is a nonempty string
// @param {any} str The object to check
// @returns {boolean} true if str is a nonempty string, false otherwise
function isNonEmptyString (str) {
return typeof str === 'string' && str.length
}
// Lists up to MAX_CARDS_PER_PAGE cards from a column
// @param {integer} columnId The id of the column containing the cards
// @param {integer} pageNumber The page of up to MAX_CARDS_PER_PAGE cards to retrieve
// default 1
// @return {Promise} A promise representing fetching the page of cards
// @fulfilled {Array} The card data as an array of objects
// @throws {TypeError} for a parameter of the incorrect type
// @throws {RangeError} if columnId is negative
// @throws {RangeError} if pageNumber is less than 1
// @throws {Error} if an error occurs while trying to fetch the card data
async function getCardPage (columnId, pageNumber = 1) {
if (typeof columnId === 'string') {
columnId = parseInt(columnId)
if (!columnId) { // The column id isn't going to be 0
throw new TypeError('Param columnId is not an integer')
}
}
if (typeof pageNumber === 'string') {
pageNumber = parseInt(pageNumber)
if (!pageNumber) { // The column id isn't going to be 0
throw new TypeError('Param pageNumber is not an integer')
}
}
if (!Number.isInteger(columnId)) {
throw new TypeError('Param columnId is not an integer')
} else if (columnId < 0) {
throw new RangeError('Param columnId cannot be negative')
}
if (!Number.isInteger(pageNumber)) {
throw new TypeError('Param pageNumber is not an integer')
} else if (pageNumber < 1) {
throw new RangeError('Param pageNumber cannot be less than 1')
}
return await octokit.projects.listCards({
column_id: columnId,
archived_state: 'not_archived',
page: pageNumber,
per_page: MAX_CARDS_PER_PAGE
})
}
// Get a column by name in a project
// @param {string} columnName The name of the column
// @param {integer} projectId The id of the project containing the column
// @return {Promise} A promise representing fetching of the column
// @fulfilled {Object} An object representing the first column with name matching columnName
// undefined if the column could not be found
// @throws {TypeError} for a parameter of the incorrect type
// @throws {RangeError} if projectId is less than 1
// @throws {Error} if an error occurs while trying to fetch the project data
async function getColumn (columnName, projectId) {
if (typeof projectId === 'string') {
columnId = parseInt(projectId)
if (!projectId) { // The project id isn't going to be 0
throw new TypeError('Param projectId is not an integer')
}
}
if (!Number.isInteger(projectId)) {
throw new TypeError('Param projectId is not an integer')
} else if (projectId < 0) {
throw new RangeError('Param projectId cannot be negative')
}
const columnList = await octokit.request('GET /projects/{project_id}/columns', {
project_id: projectId
})
return columnList.data.find((column) => {
return column.name === columnName
})
}
// Lists all the cards for a column that are issues
// @param {integer} columnId The id of the column containing the cards
// @return {Promise} A promise representing fetching of card data
// @fulfilled {Array} The card data as an array of objects
// @throws {TypeError} for a parameter of the incorrect type
// @throws {RangeError} if columnId is negative
// @throws {Error} if an error occurs while trying to fetch the card data
async function getColumnCardIssues (columnId) {
if (typeof columnId === 'string') {
columnId = parseInt(columnId)
if (!columnId) { // The column id isn't going to be 0
throw new TypeError('Param columnId is not an integer')
}
}
if (!Number.isInteger(columnId)) {
throw new TypeError('Param columnId is not an integer')
} else if (columnId < 0) {
throw new RangeError('Param columnId cannot be negative')
}
let cardIssues = []
let cardPage
let page = 1
do {
cardPage = await getCardPage(columnId, page)
// filter out non issue cards
let pageCardIssues = cardPage.data.filter((card) => {
return card.content_url
})
cardIssues.push(...pageCardIssues)
page++
} while (cardPage.data.length >= MAX_CARDS_PER_PAGE)
return cardIssues
}
// Get the project with name passed into projectName from the current repo
// @param {string} projectName The name of the project
// @return {Promise} A promise representing fetching of the project
// @fulfilled {Object} An object representing the first project with name matching projectName
// undefined if the project could not be found
// @throws {TypeError} for a parameter of the incorrect type
// @throws {Error} if an error occurs while trying to fetch the project data
async function getProject (projectName) {
if (!isNonEmptyString(projectName)) {
throw new TypeError('Param projectName must be a non empty string')
}
const repoProjects = await octokit.request('GET /repos/{owner}/{repo}/projects', {
owner: owner,
repo: repo
})
return repoProjects.data.find((project) => {
return project.name === projectName
})
}
// Adds a label to a card if it is an issue
// @param {object} card An object representing the card to be labeled
// @param {Array} labels The list of labels to be added
// @return {Promise} A promise representing the labeling of the card
// @throws {TypeError} for a parameter of the incorrect type
// @throws {Error} if an error occurs while labeling the card
async function labelCardIssue (card, labels) {
if (!isObject(card)) {
throw new TypeError('Param card is not an object')
}
if (!Array.isArray(labels)) {
reject(new TypeError('Param labels must be an array'))
}
if (!card.content_url) {
throw new ReferenceError(`Card with id: ${ card.id } is missing field "content_url"`)
}
const issueNumberMatchCapture = card.content_url.match(/\/issues\/(\d+)$/)
if (!issueNumberMatchCapture || issueNumberMatchCapture.length < 2) {
throw new Error(`Failed to extract issue number from url: ${card.content_url}`)
}
const issueNumber = issueNumberMatchCapture[1]
return octokit.issues.addLabels({
owner: owner,
repo: repo,
issue_number: issueNumber,
labels: labels
})
}
// Adds a github labels to each card of a list
// @param {Array} cardData The list of cards to be labeled
// @param {Array} labels The list of labels to be added
// @return {Promise} A promise representing labeling the list of cards
// @fulfilled {integer} The number of cards successfully labeled
// @rejected {TypeError} for a parameter of the incorrect type
function labelCards(cardData, labels) {
const delayBetweenRequestsMS = cardData.length >= MAX_CARDS_PER_PAGE ? 1000 : 0
if (delayBetweenRequestsMS) {
console.log('INFO: A large number of label issue requests will be sent. Throttling requests.')
}
return new Promise((resolve, reject) => {
if (!Array.isArray(cardData)) {
reject(new TypeError('Param cardData must be an array'))
return
}
if (!(cardData.length)) {
resolve(0)
return
}
if (!Array.isArray(labels)) {
reject(new TypeError('Param labels must be an array'))
return
}
let cardLabelAttemptCount = 0
let cardsLabeledCount = 0
let requestSentCount = 0
const requestInterval = setInterval(() => {
const card = cardData[requestSentCount]
labelCardIssue(card, labels).then(() => {
cardsLabeledCount++
}).catch((e) => {
console.warn(`WARNING: Failed to label card with id: ${card.id}`)
console.warn(e.message)
}).finally(() => {
cardLabelAttemptCount++
if (cardLabelAttemptCount >= cardData.length) {
resolve(cardsLabeledCount)
}
})
if (++requestSentCount >= cardData.length) {
clearInterval(requestInterval)
}
}, delayBetweenRequestsMS)
})
}
// Validates a list of lables contains only strings
// @param {number} column_labels_index The index of the column_labels object in the user args (for printing errors)
// @param {array} labels An array of labels as strings
// @return {array} An array of the valid labels
// @throws {TypeError} for a parameter of the incorrect type
function validateLabels (column_labels_index, labels) {
if (!Number.isInteger(column_labels_index)) {
throw new TypeError('Param column_labels_index must be an integer')
}
if (!Array.isArray(labels)) {
throw new TypeError('Param labels must be an array')
}
return labels.filter((label) => {
const isValidLabel = isNonEmptyString(label)
if (!isValidLabel) {
console.warn(`WARNING: element at index=${column_labels_index} of columns_labels contains an invalid label: ${label}`)
console.warn(` Labels must be non empty strings`)
console.warn(` Omitting invalid label`)
}
return isValidLabel
})
}
// Validates an object containing a github column identifyer and a list of labels to add
// Removes extra or unusable data from the object
// @param {string} column_labels The object to be validated
// @param {integer} column_labels_index The index of the column_labels object in the user args(for error printing)
// @throws {Error} When the column-labels object is fatally invalid
function validateColumnLabels (column_labels, column_labels_index) {
if (!isObject(column_labels)) {
throw new TypeError(`WARNING: element at index=${column_labels_index} of columns_labels is not an object`)
}
if (!('labels' in column_labels)) {
throw new ReferenceError(`WARNING: element at index=${column_labels_index} of columns_labels is missing key "labels"`)
}
if ('column_id' in column_labels && isNonEmptyString(column_labels['column_id'])) {
delete column_labels['column_name']
delete column_labels['project_name']
} else if (('column_name' in column_labels) && isNonEmptyString(column_labels['column_name']) && ('project_name' in column_labels) && isNonEmptyString(column_labels['project_name'])) {
delete column_labels['column_id']
} else {
throw new ReferenceError(`WARNING: element at index=${column_labels_index} of columns_labels does not contain valid identifiers for a github column`)
}
let filtered_labels
try {
filtered_labels = validateLabels(column_labels_index, column_labels['labels'])
} catch (e) {
console.warn(e.message.split('\n', 1)[0])
filtered_labels = []
}
if (!filtered_labels.length) {
throw new Error(`WARNING: element at index=${column_labels_index} of columns_labels does not contain valid labels`)
}
column_labels.labels = filtered_labels
}
// Validates the columns_labels user arg
// @param {string} columns_labels_as_string The value of columns_labels passed by the bot user
// @return {array} An array of the valid objects containing column and label data
// @throws {Error} When the arguments are fatally invalid
function validateColumnsLabels (columns_labels_as_string) {
let columns_labels_as_Object
try {
columns_labels_as_Object = JSON.parse(columns_labels_as_string)
} catch (e) {
console.error('ERROR: Could not parse param columns_labels as JSON')
throw e
}
if (!Array.isArray(columns_labels_as_Object)) {
throw new TypeError('ERROR: param columns_labels must be an array')
}
const valid_columns_labels = columns_labels_as_Object.filter((column_labels, index) => {
try {
validateColumnLabels(column_labels, index)
return true
} catch (e) {
console.warn(e.message.split('\n', 1)[0])
console.warn(` Skipping element at index=${index}`)
return false
}
})
if (!valid_columns_labels.length) {
throw new Error('ERROR: Could not find a valid object with a column identifier and a list of labels')
}
return valid_columns_labels
}
async function main () {
const validColumnsLabels = validateColumnsLabels(columns_labels)
for (const column_labels of validColumnsLabels) {
let columnId = column_labels['column_id']
console.log(`Labeling a column with the following column label data: ${ JSON.stringify(column_labels) }`)
if (!columnId) {
let project
try {
project = await getProject(column_labels['project_name'])
} catch (e) {
console.error(`ERROR: Failed to find project with name "${column_labels['project_name']}"`)
console.error(' Skipping labeling using the above data')
console.error(e.message)
continue
}
try {
const column = await getColumn(column_labels['column_name'], project.id)
columnId = column ? column.id : null
if (!columnId) {
throw new Error('')
}
} catch (e) {
console.error(`ERROR: Failed to find column with name ${column_labels['column_name']}`)
console.error(' Skipping labeling using the above data')
console.error(e.message)
continue
}
}
let cards
try {
cards = await getColumnCardIssues(columnId)
} catch (e) {
console.error('ERROR: Failed to fetch card data')
console.error(' Skipping labeling using the above data')
console.error(e.message)
continue
}
const cardsLabeledCount = await labelCards(cards, column_labels['labels'])
console.log(`Labeled/relabeled ${cardsLabeledCount} of ${cards.length} card issues`)
}
return
}
main().catch((e) => {
console.error(e.message)
process.exit(1)
})