zalando/zappr

View on GitHub
server/checks/Approval.js

Summary

Maintainability
F
3 days
Test Coverage
import Check from './Check'
import AuditEvent from '../service/audit/AuditEvent'
import { logger, formatDate } from '../../common/debug'
import { promiseReduce, getIn, toGenericComment } from '../../common/util'
import * as EVENTS from '../model/GithubEvents'
import * as AUDIT_EVENTS from '../service/audit/AuditEventTypes'
import * as _ from 'lodash'

const IGNORE_LOGIN_RE =[/-robot$/]

const context = 'zappr'
const info = logger('approval', 'info')
const debug = logger('approval')
const error = logger('approval', 'error')

  /**
   * Checks if login approvals shouldn't be ignored
   *
   * @param login Github login of the commente
   * @returns {Boolean} `true` if the approval should be counted
   */
function commenterIsNotIgnored (login) {
  return IGNORE_LOGIN_RE.reduce((accumulator, currentValue) => accumulator && !RegExp(currentValue).test(login), true);
}


export default class Approval extends Check {

  static TYPE = 'approval'
  static CONTEXT = context
  static NAME = 'Approval check'
  static HOOK_EVENTS = [EVENTS.PULL_REQUEST, EVENTS.ISSUE_COMMENT]

  /**
   * @param {GithubService} github
   * @param {PullRequestHandler} pullRequestHandler
   * @param {AuditService} auditService
   */
  constructor(github, pullRequestHandler, auditService) {
    super()
    this.github = github
    this.pullRequestHandler = pullRequestHandler
    this.audit = auditService
  }

  /**
   * Checks if a database entry exists for the given pull request number
   * and returns it if it exists. Otherwise creates and returns a new one.
   *
   * @param dbRepoId The db id for the repository
   * @param {RepositoryHandler} Numbe/github identifier of the pull request
   * @returns {Object} The pull request information stored/created in the database.
   */
  async getOrCreateDbPullRequest(dbRepoId, number) {
    let dbPR = await this.pullRequestHandler.onGet(dbRepoId, number)
    if (!dbPR) {
      dbPR = await this.pullRequestHandler.onCreatePullRequest(dbRepoId, number)
    }
    return dbPR;
  }

  /**
   * Based on the Zappr approval configuration
   * and the approval statistics and number of vetos.
   * Generates a commit status object that it consumed by the Github Status API
   * (https://developer.github.com/v3/repos/statuses/#create-a-status).
   *
   * @param approvals Approval stats. Includes `total` approvals and group information.
   * @param vetos Number of vetos.
   * @param approvalConfig Approval configuration
   * @returns {Object} Object consumable by Github Status API
   */
  static generateStatus({approvals, vetos}, {minimum, groups}) {
    if (vetos.length > 0) {
      return {
        description: `Vetoes: ${vetos.map(u => `@${u}`).join(', ')}.`,
        state: 'failure',
        context
      }
    }

    if (Object.keys(approvals.groups || {}).length > 0) {
      // check group requirements
      const unsatisfied = Object.keys(approvals.groups)
                                .reduce((result, approvalGroup) => {
                                  const needed = groups[approvalGroup].minimum
                                  const given = approvals.groups[approvalGroup].length
                                  const diff = needed - given
                                  if (diff > 0) {
                                    result.push({approvalGroup, diff, needed, given})
                                  }
                                  return result
                                }, [])

      if (unsatisfied.length > 0) {
        const firstUnsatisfied = unsatisfied[0]
        return {
          description: `This PR misses ${firstUnsatisfied.diff} approvals from group ${firstUnsatisfied.approvalGroup} (${firstUnsatisfied.given}/${firstUnsatisfied.needed}).`,
          state: 'pending',
          context
        }
      }
    }

    if (approvals.total.length < minimum) {
      return {
        description: `This PR needs ${minimum - approvals.total.length} more approvals (${approvals.total.length}/${minimum} given).`,
        state: 'pending',
        context
      }
    }

    return {
      description: `Approvals: ${approvals.total.map(u => `@${u}`).join(', ')}.`,
      state: 'success',
      context
    }
  }

  /**
   * Returns the comment if it matches the config, null otherwise.
   */
  async doesCommentMatchConfig(repository, comment, fromConfig, token) {
    // persons must either be listed explicitly in users OR
    // be a collaborator OR
    // member of at least one of listed orgs
    const {orgs, collaborators, users} = fromConfig
    const username = comment.user
    const {full_name} = repository
    // first do the quick username check
    if (users && users.indexOf(username) >= 0) {
      debug(`${full_name}: ${username} is listed explicitly`)
      return comment
    }
    // now collaborators
    if (collaborators) {
      const isCollaborator = await this.github.isCollaborator(repository.owner.login, repository.name, username, token)
      if (isCollaborator) {
        debug(`${full_name}: ${username} is collaborator`)
        return comment
      }
    }
    // and orgs
    if (orgs) {
      const orgMember = await Promise.all(orgs.map(o => this.github.isMemberOfOrg(o, username, token)))
      if (orgMember.indexOf(true) >= 0) {
        debug(`${full_name}: ${username} is org member`)
        return comment
      }
    }
    debug(`${full_name}: ${username}'s approval does not count`)
    // okay, no member of anything
    return null
  }

  /**
   * Counts how many comments are there in total and per group.
   *
   * @param repository The repository
   * @param comments The comments to process
   * @param config The approval/veto configuration
   * @param token The access token to use
   * @returns {Object} Object of the shape {total: int, groups: { groupName: int } }
   */
  async getCommentStatsForConfig(repository, comments, config, token) {
    const that = this

    async function checkComment(stats, comment) {
      let matchesTotal = false
      if (config.from) {
        matchesTotal = await that.doesCommentMatchConfig(repository, comment, config.from, token)
        if (matchesTotal) {
          info(`${repository.full_name}: Counting ${comment.user}'s comment`)
          stats.total.push(comment.user)
        }
      } else {
        // if there is no from clause, every comment counts
        stats.total.push(comment.user)
        matchesTotal = true
      }
      if (config.groups) {
        await Promise.all(Object.keys(config.groups).map(async(group) => {
          // update group counter
          if (!stats.groups[group]) {
            stats.groups[group] = []
          }
          const matchesGroup = await that.doesCommentMatchConfig(repository, comment, config.groups[group].from, token)
          if (matchesGroup) {
            // counting this as total as well if it didn't before
            if (!matchesTotal) {
              info(`${repository.full_name}: Counting ${comment.user}'s comment`)
              stats.total.push(comment.user)
            }
            info(`${repository.full_name}: Counting ${comment.user}'s comment for group ${group}`)
            stats.groups[group].push(comment.user)
          }
        }))
      }
      return stats
    }

    return promiseReduce(comments, checkComment, {total: [], groups: {}})
  }

  /**
   * Removes comments from ignored users for approvals, comments that do not match approval/veto
   * pattern as well as multiple approvals/vetos by the same person and counts the
   * remaining approvals/vetos.
   *
   * @param repository The repository
   * @param pull_request The pull request
   * @param comments The comments to process
   * @param config The approval configuration
   * @param token The access token to use
   * @returns {Object} Object of the shape {approvals: {total: int, groups: { groupName: int }}, vetos: int }
   */
  async countApprovalsAndVetos(repository, pull_request, comments, config, token) {
    const ignore = await this.fetchIgnoredUsers(repository, pull_request, config, token)
    const approvalPattern = config.pattern
    const vetoPattern = _.get(config, 'veto.pattern')

    const fullName = `${repository.full_name}`
    // slightly unperformant filtering here:
    const containsAlreadyCommentByUser = (c1, i, cmts) => i === cmts.findIndex(c2 => c1.user === c2.user)
    // filter ignored users
    const potentialApprovalComments = comments.filter(comment => {
                                                const login = comment.user
                                                // comment is ignored if login is in ignored list or matches a global ignored regex array
                                                const include = (ignore.indexOf(login) === -1 && commenterIsNotIgnored(login));
                                                if (!include) {
                                                  info('%s: Ignoring user: %s.', fullName, login)
                                                }
                                                return include
                                              })
                                              // get comments that match specified approval pattern
                                              // TODO add unicode flag once available in node
                                              .filter(comment => {
                                                const text = comment.body.trim()
                                                const include = (new RegExp(approvalPattern)).test(text)
                                                if (!include) {
                                                  info('%s: %s\'s comment "%s" does not match pattern "%s".', fullName, comment.user, text.substring(0, 16), approvalPattern)
                                                }
                                                return include
                                              })
                                              // kicking out multiple approvals from same person
                                              .filter(containsAlreadyCommentByUser)


    const approvals = (config.from || config.groups) ?
      await this.getCommentStatsForConfig(repository, potentialApprovalComments, config, token) :
    {total: potentialApprovalComments.map(c => c.user)}


    let vetos = []
    if (vetoPattern) {
      const potentialVetoComments = comments.filter(comment => {
                                              const text = comment.body.trim()
                                              const include = (new RegExp(vetoPattern)).test(text)
                                              return include
                                            })
                                            // kicking out multiple vetos from same person
                                            .filter(containsAlreadyCommentByUser)

      vetos = (config.from || config.groups) ?
        (await this.getCommentStatsForConfig(repository, potentialVetoComments, config, token)).total :
        potentialVetoComments.map(c => c.user)

    }

    return {
      approvals,
      vetos
    }
  }

  /**
   * Gets the users to ignore for a pull request in a repository, according to its
   * approval configuration.
   *
   * @param github The GithubService instance
   * @param repository The repository
   * @param pull_request The pull request
   * @param config The approval configuration
   * @param token The access token to use
   * @returns {Array} The logins of users to ignore
   */
  async fetchIgnoredUsers(repository, pull_request, config, token) {
    if (!config.ignore || config.ignore === 'none') {
      return []
    }
    const ignoreConfig = config.ignore
    const user = repository.owner.login
    const repoName = repository.name
    const ignoredUsers = []
    if (ignoreConfig === 'last_committer' || ignoreConfig === 'both') {
      const lastCommitter = await this.github.fetchLastCommitter(user, repoName, pull_request.number, token)
      if (lastCommitter) {
        ignoredUsers.push(lastCommitter)
      }
    }
    if (ignoreConfig === 'pr_opener' || ignoreConfig === 'both') {
      const prOpener = getIn(pull_request, ['user', 'login'])
      if (prOpener) {
        ignoredUsers.push(prOpener)
      }
    }
    return ignoredUsers
  }

  /**
   * Fetches data necessary to count approvals/vetos (e.g. when was last push on pull request,
   * comments on this pull request from Github) and counts approvals and vetos.
   *
   * @param repository The repository
   * @param config The approval configuration
   * @param pull_request The pull request
   * @param last_push When the pull request was last updated
   * @param frozenComments Frozen comments from database will overwrite upstream comments
   * @param token The access token to use
   * @returns {Object} Object of the shape {approvals: {total: int, groups: { groupName: int }}, vetos: int }
   */
  async fetchAndCountApprovalsAndVetos(repository, pull_request, last_push, frozenComments, config, token) {
    const user = repository.owner.login
    const upstreamComments = await this.github.getComments(user, repository.name, pull_request.number, formatDate(last_push), token)
    const comments = [...frozenComments, ...upstreamComments].filter((c, idx, array) => idx === array.findIndex(c2 => c.id === c2.id))
    return await this.countApprovalsAndVetos(repository, pull_request, comments, config.approvals, token)
  }

  /**
   * Fetches approvals for pull request and sets status on head commit.
   *
   * @param repository The repository object from GH
   * @param pull_request The pull_request object from GH
   * @param lastPush Time of last push to this PR
   * @param config The Zappr configuration
   * @param token The GH token to use
   * @param additionalComments Additional comments to consider that are not available via the API
   */
  async fetchApprovalsAndSetStatus(repository, pull_request, lastPush, config, token, additionalComments = []) {
    const user = repository.owner.login
    const repoName = repository.name
    const sha = pull_request.head.sha
    const {approvals, vetos} = await this.fetchAndCountApprovalsAndVetos(repository, pull_request, lastPush, additionalComments, config, token)
    const status = Approval.generateStatus({approvals, vetos}, config.approvals)
    // update status
    await this.github.setCommitStatus(user, repoName, sha, status, token)
    await this.audit.log(new AuditEvent(AUDIT_EVENTS.COMMIT_STATUS_UPDATE).fromGithubEvent({
                                                                            repository,
                                                                            pull_request
                                                                          })
                                                                          .withResult({
                                                                            approvals,
                                                                            vetos,
                                                                            status
                                                                          })
                                                                          .onResource({
                                                                            commit: sha,
                                                                            issue_number: pull_request.number,
                                                                            repository
                                                                          }))
    info(`${repository.full_name}#${pull_request.number}: Set state to ${status.state} (${approvals.total.length}/${config.approvals.minimum} approvals, ${vetos.length ? 'vetos: ' + vetos : '0 vetos'})`)
  }


  /**
   * Executes approval check.
   *
   * - PR open/reopen:
   *   1. set status to pending
   *   2. count approvals since last commit
   *   3. set status to ok when there are enough approvals
   * - IssueComment create/delete:
   *   1. verify it's on an open pull request
   *   2. set status to pending for open PR
   *   3. count approvals since last commit
   *   4. set status to ok when there are enough approvals
   * - PR synchronize (new commits on top):
   *   1. set status back to pending (b/c there can't be comments afterwards already)
   *
   * @param config The Zappr configuration (all of it)
   * @param event The GitHub event, e.g. pull_request
   * @param hookPayload The payload of the call
   * @param token The GitHub token to use
   * @param dbRepoId The database ID of the affected repository
   */
  async execute(config, event, hookPayload, token, dbRepoId) {
    const {action, repository, pull_request, number, issue} = hookPayload
    const repoName = repository.name
    const user = repository.owner.login
    const {minimum} = config.approvals
    let sha = ''
    const pendingPayload = {
      state: 'pending',
      description: 'Approval validation in progress.',
      context
    }

    debug(`${repository.full_name}: Got hook`)

    // on a closed pull request
    if (event === EVENTS.PULL_REQUEST && pull_request.state === 'closed') {
      // if it was merged
      if (pull_request.merged) {
        await this.pullRequestHandler.onDeletePullRequest(dbRepoId, number)
        await this.audit.log(new AuditEvent(AUDIT_EVENTS.PULL_REQUEST_MERGED).fromGithubEvent(hookPayload)
                                                                             .onResource({
                                                                               repository,
                                                                               pull_request,
                                                                               issue_number: number
                                                                             })
                                                                             .byUser(pull_request.merged_by.login))
      }
      // no further processing needed
      return
    }


    try {
      // on an open pull request
      if (event === EVENTS.PULL_REQUEST && pull_request.state === 'open') {
        sha = pull_request.head.sha
        const dbPR = await this.getOrCreateDbPullRequest(dbRepoId, number)
        // if it was (re)opened
        if (action === 'opened' || action === 'reopened') {
          // set status to pending first
          await this.github.setCommitStatus(user, repoName, sha, pendingPayload, token)

          if (action === 'opened' && minimum > 0) {
            // if it was opened, set to pending
            const approvals = {total: []}
            const vetos = []
            const status = Approval.generateStatus({approvals, vetos}, config.approvals)
            await this.github.setCommitStatus(user, repoName, sha, status, token)
            await this.audit.log(new AuditEvent(AUDIT_EVENTS.COMMIT_STATUS_UPDATE).fromGithubEvent(hookPayload)
                                                                                  .withResult({
                                                                                    approvals,
                                                                                    vetos,
                                                                                    status
                                                                                  })
                                                                                  .onResource({
                                                                                    commit: sha,
                                                                                    issue_number: number,
                                                                                    repository
                                                                                  }))
            info(`${repository.full_name}#${number}: PR was opened, set state to pending`)
            return
          }
          // get approvals for pr
          info(`${repository.full_name}#${number}: PR was reopened`)
          const frozenComments = await this.pullRequestHandler.onGetFrozenComments(dbPR.id, dbPR.last_push)
          await this.fetchApprovalsAndSetStatus(repository, pull_request, dbPR.last_push, config, token, frozenComments)
          // if it was synced, ie a commit added to it
        } else if (action === 'synchronize') {
          // update last push in db
          await this.pullRequestHandler.onAddCommit(dbRepoId, number)
          // remove frozen comments
          await this.pullRequestHandler.onRemoveFrozenComments(dbPR.id)
          // set status to pending (has to be unlocked with further comments)
          const approvals = {total: []}
          const vetos = []
          const status = Approval.generateStatus({approvals, vetos}, config.approvals)
          await this.github.setCommitStatus(user, repoName, sha, status, token)
          await this.audit.log(new AuditEvent(AUDIT_EVENTS.COMMIT_STATUS_UPDATE).fromGithubEvent(hookPayload)
                                                                                .withResult({
                                                                                  approvals,
                                                                                  vetos,
                                                                                  status
                                                                                })
                                                                                .onResource({
                                                                                  commit: sha,
                                                                                  issue_number: number,
                                                                                  repository
                                                                                }))
          info(`${repository.full_name}#${number}: PR was synced, set state to pending`)
        }
        // on an issue comment
      } else if (event === EVENTS.ISSUE_COMMENT) {
        // check it belongs to an open pr
        const pr = await this.github.getPullRequest(user, repoName, issue.number, token)
        if (!pr || pr.state !== 'open') {
          debug(`${repository.full_name}#${issue.number}: Ignoring comment, not a PR`)
          return
        }
        const author = hookPayload.comment.user.login
        if (!commenterIsNotIgnored(author)) {
          debug(`${repository.full_name}#${issue.number}: Ignoring comment, it was created by a robot user.`)
          return
        }
        sha = pr.head.sha
        // set status to pending first
        await this.github.setCommitStatus(user, repoName, sha, pendingPayload, token)
        // read last push date from db
        const dbPR = await this.getOrCreateDbPullRequest(dbRepoId, issue.number)
        // read frozen comments and update if appropriate
        const frozenComments = await this.pullRequestHandler.onGetFrozenComments(dbPR.id, dbPR.last_push)
        const commentId = hookPayload.comment.id
        const commentAlreadyFrozen = frozenComments.map(comment => comment.id).indexOf(commentId) !== -1
        if (['edited', 'deleted'].indexOf(action) !== -1 && !commentAlreadyFrozen) {
          // check if it was edited by someone else than the original author
          const editor = hookPayload.sender.login
          if (editor !== author) {
            // OMFG
            const comment = toGenericComment(hookPayload.comment)
            const frozenComment = {
              id: commentId,
              body: action === 'edited' ? hookPayload.changes.body.from : comment.body,
              user: comment.user,
              created_at: comment.created_at
            }
            await this.pullRequestHandler.onAddFrozenComment(dbPR.id, frozenComment)
            if (new Date(comment.created_at) > dbPR.last_push) {
              frozenComments.push(frozenComment)
            }
            info(`${repository.full_name}#${issue.number}: ${editor} ${action} ${author}'s comment ${commentId}, it's now frozen.`)
          }
        }
        debug(`${repository.full_name}#${issue.number}: Comment added`)
        await this.fetchApprovalsAndSetStatus(repository, pr, dbPR.last_push, config, token, frozenComments)
      }
    }
    catch (e) {
      error(e)
      await this.github.setCommitStatus(user, repoName, sha, {
        state: 'error',
        context,
        description: e.message
      }, token)
    }
  }

}