zalando/zappr

View on GitHub
server/checks/Specification.js

Summary

Maintainability
A
1 hr
Test Coverage
import Check from './Check'
import { logger } from '../../common/debug'
import * as EVENTS from '../model/GithubEvents'

const CHECK_TYPE = 'specification'
const CONTEXT = 'zappr/pr/specification'
const ACTIONS = ['opened', 'edited', 'reopened', 'synchronize']

const DEFAULT_REQUIRED_LENGTH = 8
const ISSUE_PATTERN = /^(?:[-\w]+\/[-\w]+)?#\d+$/
const URL_PATTERN = /\bhttps?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/i
const [MINIMUM_LENGTH, CONTAINS_URL, CONTAINS_ISSUE_NUMBER, TEMPLATE_DIFFERS_FROM_BODY] =
  ['minimum-length', 'contains-url', 'contains-issue-number',
    'differs-from-body']

const debug = logger(CHECK_TYPE)
const info = logger(CHECK_TYPE, 'info')
const error = logger(CHECK_TYPE, 'error')

const status = (description, state = 'success') => ({
  description,
  state,
  context: CONTEXT
})

const isLongEnough = (str, requiredLength) => (str || '').length > requiredLength
const containsPattern = pattern => str => (str || '').split(' ')
                                                     .some(s => pattern.test(s))
const containsUrl = containsPattern(URL_PATTERN)
const containsIssueNumber = containsPattern(ISSUE_PATTERN)

export default class Specification extends Check {
  static TYPE = CHECK_TYPE
  static CONTEXT = CONTEXT
  static NAME = 'Specification check'
  static HOOK_EVENTS = [EVENTS.PULL_REQUEST]

  /**
   * @param {GithubService} github
   */
  constructor(github) {
    super()
    this.github = github
  }

  async execute(config, hookPayload, token) {
    const {action, pull_request: pr, repository: repo} = hookPayload

    if (ACTIONS.indexOf(action) === -1 || !pr || 'open' !== pr.state) {
      debug(`${repo.full_name}#${pr.number}: Nothing to do, action was "${action}" with state "${pr.state}".`)
      return
    }

    await this.validate(config, pr, repo, token)
  }

  /**
   * Should do next validation steps:
   *  * check if title's length is more than required length
   *  * check if body has one of the following (in order):
   *    * at least one issue number
   *    * at least one link
   *    * it's length is more than required length
   *
   * @param config config object
   * @param pr Github's PR object
   * @param repo Github's repository object
   * @param token access token
   */
  async validate(config, pr, repo, token) {
    const {title = '', body = '', head: {sha}} = pr
    const {owner: {login: user}} = repo
    const {
      specification: {
        title: titleChecks = {},
        body: bodyChecks = {},
        template: templateChecks = {}
      } = {}
    } = config

    try {
      await Promise.all([
        this._validateTitle(title, titleChecks),
        this._validateTemplate(body, user, repo.name, token, templateChecks),
        this._validateBody(body, bodyChecks)
      ])
      info(`${repo.full_name}#${pr.number}: Set status to success`)
      return this.github.setCommitStatus(user, repo.name, sha,
        status('PR has passed specification checks'), token)
    } catch (e) {
      info(`${repo.full_name}#${pr.number}: Set status to failure: ${e.message}`)
      return this.github.setCommitStatus(user, repo.name, sha, status(
        e.message, 'failure'), token)
    }
  }

  /**
   *
   * @param {string} title to be validated
   * @param {Object} checks part of `specification` that contains title's checks
   */
  _validateTitle(title, checks = {}) {
    const {
      [MINIMUM_LENGTH]: {
        enabled: shouldCheckLength = true,
        length: requiredLength = DEFAULT_REQUIRED_LENGTH
      } = {}
    } = checks

    if (shouldCheckLength && !isLongEnough(title, requiredLength)) {
      throw new Error(`PR's title is too short (${title.length}/${requiredLength})`)
    }
  }

  async _validateTemplate(body, user, repo, token, checks = {}) {
    const shouldCheckWasAdjusted = checks[TEMPLATE_DIFFERS_FROM_BODY]
    if (!shouldCheckWasAdjusted) {
      return
    }
    let template
    try {
      template = await this.github.readPullRequestTemplate(user, repo, token)
    } catch (e) {
      info(`${user}/${repo}: No PULL_REQUEST_TEMPLATE found`)
      return
    }
    if (template.trim() === body.trim()) {
      throw new Error(`PR's body is the same as template`)
    }
  }

  /**
   * @param {string} body to be validated
   * @param {Object} checks part of `specification` that contains body's checks
   */
  _validateBody(body, checks = {}) {
    const {
      [MINIMUM_LENGTH]: {
        enabled: shouldCheckLength = true,
        length: requiredLength = DEFAULT_REQUIRED_LENGTH
      } = {},
      [CONTAINS_URL]: shouldCheckUrl = true,
      [CONTAINS_ISSUE_NUMBER]: shouldCheckIssue = true
    } = checks

    const checksMapping = {
      [CONTAINS_URL]: {
        enabled: shouldCheckUrl,
        fn: containsUrl.bind(null, body)
      },
      [CONTAINS_ISSUE_NUMBER]: {
        enabled: shouldCheckIssue,
        fn: containsIssueNumber.bind(null, body)
      },
      [MINIMUM_LENGTH]: {
        enabled: shouldCheckLength,
        fn: isLongEnough.bind(null, body, requiredLength)
      }
    }

    // array to force the order
    const [success, failedChecks] = [
      CONTAINS_ISSUE_NUMBER, CONTAINS_URL, MINIMUM_LENGTH
    ].reduce(([success, failedChecks], checkName) => {
      const {enabled, fn: check} = checksMapping[checkName]

      if (enabled) {
        const res = check()
        if (!res) {
          failedChecks.push(`'${checkName}'`)
        }

        success = success || res
      }

      return [success, failedChecks]
    }, [false, []])

    if (!success && failedChecks.length > 0) {
      throw new Error(`PR's body failed check ${failedChecks[0]}`)
    }
  }
}