packages-check-bot/index.js

280 lines
9.8 KiB
JavaScript

// Checks API example
// See: https://developer.github.com/v3/checks/ to learn more
const pendingChecks = []
const Humanize = require('humanize-plus')
module.exports = app => {
app.on(['check_suite.requested'], checkSuiteRequested)
app.on(['check_run.rerequested'], checkRunRerequested)
async function checkSuiteRequested(context) {
if (context.isBot) {
return
}
await checkSuiteAsync(context, context.payload.check_suite)
}
async function checkRunRerequested(context) {
if (context.isBot) {
return
}
const { check_suite } = context.payload.check_run
await checkSuiteAsync(context, check_suite)
}
async function checkSuiteAsync(context, check_suite) {
// Do stuff
try {
const { head_branch, head_sha } = check_suite
if (pendingChecks[head_sha]) {
// Already running, ignore
return
}
// Probot API note: context.repo() => {username: 'hiimbex', repo: 'testing-things'}
const check = context.repo({
name: 'packages-check-bot',
head_branch: head_branch,
head_sha: head_sha,
status: 'in_progress',
started_at: (new Date()).toISOString(),
output: {
title: 'package.json check',
summary: 'Checking any new/updated dependencies...'
}
})
if (context.payload.action === 'rerequested') {
pendingChecks[head_sha] = { ...check, check_run_id: context.payload.check_run.id }
queueCheckAsync(context, check_suite)
return {statusCode: 200}
} else {
pendingChecks[head_sha] = { ...check }
const createResponse = await context.github.checks.create(check)
context.log.debug(`create checks status: ${createResponse.status}`)
pendingChecks[head_sha] = { ...check, check_run_id: createResponse.data.id }
queueCheckAsync(context, check_suite)
return createResponse
}
} catch (e) {
context.log.error(e)
// TODO: Check what is the right way to exit here, since it seems this causes the bot to not be responsive afterward
throw e
}
}
// For more information on building apps:
// https://probot.github.io/docs/
// To get your app running against GitHub, see:
// https://probot.github.io/docs/development/
}
async function queueCheckAsync(context, check_suite) {
try {
const { before, head_sha } = check_suite
const compareResponse = await context.github.repos.compareCommits(context.repo({
base: before,
head: head_sha
}))
context.log.debug(`compare commits status: ${compareResponse.status}, ${compareResponse.data.files.length} file(s)`)
let check = pendingChecks[head_sha]
let checkedDepCount = 0
let packageJsonFilenames = []
const packageFilenameRegex = /^package\.json(.orig)?$/g
check.output.annotations = undefined
for (const file of compareResponse.data.files) {
switch (file.status) {
case 'added':
case 'modified':
if (packageFilenameRegex.test(file.filename)) {
packageJsonFilenames.push(file.filename)
checkedDepCount += await checkPackageFileAsync(check, context, file, head_sha)
}
break
}
}
check.status = 'completed'
check.completed_at = (new Date()).toISOString()
if (!check.output.annotations) {
check.conclusion = 'neutral'
check.output.summary = 'No changes to dependencies'
} else if (check.output.annotations.length === 0) {
check.conclusion = 'success'
check.output.summary = 'All dependencies are good!'
} else {
check.conclusion = 'failure'
const warnings = check.output.annotations.filter(a => a.annotation_level === 'warning').length
const failures = check.output.annotations.filter(a => a.annotation_level === 'failure').length
const uniqueProblemDependencies = [...new Set(check.output.annotations.map(a => a.dependency))]
check.output.summary = `Checked ${checkedDepCount} ${Humanize.pluralize(checkedDepCount, 'dependency', 'dependencies')} in ${Humanize.oxford(packageJsonFilenames.map(f => `\`${f}\``), 3)}.
${Humanize.boundedNumber(failures, 10)} ${Humanize.pluralize(failures, 'failure')}, ${Humanize.boundedNumber(warnings, 10)} ${Humanize.pluralize(warnings, 'warning')} in ${Humanize.oxford(uniqueProblemDependencies.map(f => `\`${f}\``), 3)} need your attention!`
}
// Remove helper data from annotation objects
const annotations = check.output.annotations
for (const annotation of annotations) {
delete annotation['dependency']
}
for (let annotationIndex = 0; annotationIndex < annotations.length; annotationIndex += 50) {
const annotationsSlice = annotations.length > 50 ? annotations.slice(annotationIndex, annotationIndex + 50) : annotations
check.output.annotations = annotationsSlice
const updateResponse = await context.github.checks.update({
owner: check.owner,
repo: check.repo,
check_run_id: check.check_run_id,
name: check.name,
//details_url: check.details_url,
external_id: check.external_id,
started_at: check.started_at,
status: check.status,
conclusion: check.conclusion,
completed_at: check.completed_at,
output: check.output
}) // TODO: Handle error
context.log.debug(`update checks status: ${updateResponse.status}`)
}
check.output.annotations = annotations
delete pendingChecks[head_sha]
} catch (error) {
context.log.error(error)
throw error
}
}
async function checkPackageFileAsync(check, context, file, head_sha) {
const contentsResponse = await context.github.repos.getContents(context.repo({
path: file.filename,
ref: head_sha
}))
context.log.debug(`get contents response: ${contentsResponse.status}`)
if (contentsResponse.status >= 300) {
throw new `HTTP error ${contentsResponse.status} (${contentsResponse.statusText}) fetching ${file.filename}`
}
const contents = Buffer.from(contentsResponse.data.content, 'base64').toString('utf8')
const contentsJSON = JSON.parse(contents)
let dependencyCount = 0
dependencyCount += checkDependencies(contents, contentsJSON.dependencies, file, check)
dependencyCount += checkDependencies(contents, contentsJSON.devDependencies, file, check)
dependencyCount += checkDependencies(contents, contentsJSON.optionalDependencies, file, check)
return dependencyCount
}
function checkDependencies(contents, dependencies, file, check) {
if (!dependencies) {
return 0
}
const urlRegex = /^(http:\/\/|https:\/\/|git\+http:\/\/|git\+https:\/\/|ssh:\/\/|git\+ssh:\/\/|github:)([a-zA-Z0-9_\-./]+)(#(.*))?$/gm
const requiredProtocol = 'git+https://'
let dependencyCount = 0
for (const dependency in dependencies) {
if (dependencies.hasOwnProperty(dependency)) {
++dependencyCount
const url = dependencies[dependency]
const match = urlRegex.exec(url)
if (!match) {
continue
}
const protocol = match[1]
const address = match[2]
const tag = match.length > 4 ? match[4] : null
const { line } = findLineColumn(contents, contents.indexOf(url))
const optimalAddress = address.endsWith('.git') ? address : address.concat('.git')
const optimalTag = isTag(tag) ? tag : '#<release-tag>'
const suggestedUrl = `${requiredProtocol}${optimalAddress}${optimalTag}`
const annotationSource = {
check: check,
dependency: dependency,
file: file,
line: line
}
if (protocol !== requiredProtocol) {
createAnnotation(annotationSource, suggestedUrl, {
annotation_level: 'warning',
title: `Found protocol ${protocol} being used in dependency`,
message: `Protocol should be ${requiredProtocol}.`
})
}
if (protocol !== 'github:' && !address.endsWith('.git')) {
createAnnotation(annotationSource, suggestedUrl, {
annotation_level: 'warning',
title: 'Address should end with .git for consistency.',
message: 'Android builds have been known to fail when dependency addresses don\'t end with .git.'
})
}
if (!tag) {
createAnnotation(annotationSource, suggestedUrl, {
annotation_level: 'failure',
title: 'Dependency is not locked with a tag/release.',
message: `${url} is not a deterministic dependency locator.\r\nIf the branch advances, it will be impossible to rebuild the same output in the future.`
})
} else if (!isTag(tag)) {
createAnnotation(annotationSource, suggestedUrl, {
annotation_level: 'failure',
title: 'Dependency is locked with a branch, instead of a tag/release.',
message: `${url} is not a deterministic dependency locator.\r\nIf the branch advances, it will be impossible to rebuild the same output in the future.`
})
}
}
}
return dependencyCount
}
function findLineColumn (contents, index) {
const lines = contents.split('\n')
const line = contents.substr(0, index).split('\n').length
const startOfLineIndex = (() => {
const x = lines.slice(0)
x.splice(line - 1)
return x.join('\n').length + (x.length > 0)
})()
const col = index - startOfLineIndex
return { line, col }
}
function createAnnotation(annotationSource, suggestedUrl, annotation) {
const { check, dependency, file, line } = annotationSource
if (!check.output.annotations) {
check.output.annotations = []
}
annotation.message = annotation.message.concat(`\r\n\r\nSuggested URL: ${suggestedUrl}`)
check.output.annotations.push({
...annotation,
dependency: dependency,
path: file.filename,
start_line: line,
end_line: line,
raw_details: `{suggestedUrl: ${suggestedUrl}}`
})
}
function isTag(tag) {
// TODO: We need to check the actual repo to see if it is a branch or a tag
return tag && tag !== 'master' && tag !== 'develop'
}