parser.js

// Include the query-string package
// TODO: Use "qs" package instead?
const queryString = require('query-string')
const { Build, Deploy } = require('./models')

/**
 * Decode event.body using query-string.parse()
 * @param {object} event
 */
module.exports.decodeBody = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure event has a body
      if (!event.body) {
        reject(new Error('Event has no body to be parsed.'))
        return
      }

      try {
        // Set the parsed body
        event.parsed.body = queryString.parse(event.body)

        // Resolve this promise
        resolve(event)
      } catch (e) {
        reject(e)
      }
    }
  )
}

/**
 * Parse event.body using JSON.parse()
 * @param {object} event
 */
module.exports.parseBody = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure event has a body
      if (!event.body) {
        reject(new Error('Event has no body to be parsed.'))
        return
      }

      try {
        // Set the parsed body
        event.parsed.body = JSON.parse(event.body)

        console.log('Parsed request body:', JSON.stringify(event.parsed.body, null, 2))

        // Resolve this promise
        resolve(event)
      } catch (e) {
        reject(e)
      }
    }
  )
}

/**
 * Parse decoded body payload
 * @param {object} event
 */
module.exports.parsePayload = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure the body has already been parsed and contains a command
      if (!event.parsed.body || !event.parsed.body.payload) {
        reject(new Error('Event has no parsed body and/or payload.'))
        return
      }

      try {
        event.parsed.payload = JSON.parse(event.parsed.body.payload)

        resolve(event)
      } catch (e) {
        reject(e)
      }
    }
  )
}

/**
 * Parse SNS Records into Slack messages
 * @param {object} event
 */
module.exports.stackChange = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure the event has Records to be parsed into messages
      if (!event.Records || event.Records.length === 0) {
        reject(new Error('Event has no records to be parsed.'))
        return
      }

      // Create empty array of parsed messages
      event.parsed.messages = []

      // Loop through all event Records and build messages
      event.Records.forEach(r => {
        // Create empty message object
        const msg = {}

        // Split on new line and process each line
        r.Sns.Message.split('\n').forEach(line => {
          // Split each line into key/value pairs
          const parts = line.split('=')

          // Assign key/value pairs to message object after sanitizing
          if (parts.length === 2) {
            const name = parts[0]
            const value = parts[1].replace(/^\\|'/g, '')

            try {
              // Attempt to assign the value as a parsed object
              msg[name] = JSON.parse(value)
            } catch (e) {
              // Gracefully catch failed JSON.parse() and assign raw value
              msg[name] = value
            }
          } else {
            // Unknown line format, unable to build message with parts
            if (parts[0] && parts[0].replace(/^\\|'/g, '').trim() !== '') {
              console.log('Unknown message parts:', parts)
            }
          }
        })

        // Add message to the parsed messages array (only if it has been created)
        if (Object.keys(msg).length > 0) {
          event.parsed.messages.push(msg)
        }
      })

      // Resolve this promise
      resolve(event)
    }
  )
}

/**
 * Parse an event from a change in a CodeBuild service build
 * @param {object} event
 */
module.exports.buildChange = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure the event has detail with "build-status"
      if (!event.detail || !event.detail['build-status']) {
        reject(new Error('No build details and/or status found in event.'))
        return
      }

      const { region } = event

      let slug = event.service.source.repo

      if (event.service.source.type === 'GITHUB') {
        slug = event.detail['additional-information'].source.location.match(/^https?:\/\/github.com\/(.*)\.git$/)[1]
      } else if (event.service.source.type === 'BITBUCKET') {
        slug = event.detail['additional-information'].source.location.match(/^https?:\/\/bitbucket.org\/(.*)\.git$/)[1]
      }

      console.log('build event details:')
      console.log(JSON.stringify(event.detail, null, 2))

      // Build object for parsing build details
      const parsedBuild = {
        arn: event.detail['build-id'],
        env: event.detail['additional-information'].environment['environment-variables'],
        // arn:aws:codebuild:us-east-2:442555157363:build/sls-ci-example-app-us-east-2-build-project:d352736e-ec45-4370-9aed-1fb7d066a680
        path: event.detail['build-id'].replace(/^arn:aws:codebuild:[^:]+:[0-9]+:/g, ''),
        info: event.detail['additional-information'],
        status: event.detail['build-status'],
        complete: event.detail['additional-information']['build-complete'],
        project_name: event.detail['project-name'],
        region,
        source: {
          slug,
          location: event.detail['additional-information'].source.location,
          version: event.detail['additional-information']['source-version']
        },
        details: event.detail
      }

      console.log('parsed build data')
      console.log(JSON.stringify(parsedBuild, null, 2))

      // Find ENV vars by name
      const findBuildId = parsedBuild.env.filter(v => v.name === 'BUILD_ID')
      const findBuildBranch = parsedBuild.env.filter(v => v.name === 'BUILD_BRANCH')
      const findBuildAuthor = parsedBuild.env.filter(v => v.name === 'BUILD_AUTHOR')
      const findBuildSender = parsedBuild.env.filter(v => v.name === 'BUILD_SENDER')
      const findBuildPusher = parsedBuild.env.filter(v => v.name === 'BUILD_PUSHER')
      const findBuildRelease = parsedBuild.env.filter(v => v.name === 'BUILD_RELEASE')

      // Set additional parsedBuild fields, default to 'unknown'
      parsedBuild.id = (findBuildId.length > 0) ? findBuildId[0].value : 'unknown'
      parsedBuild.author = (findBuildAuthor.length > 0) ? findBuildAuthor[0].value : 'unknown'
      parsedBuild.sender = (findBuildSender.length > 0) ? findBuildSender[0].value : 'unknown'
      parsedBuild.pusher = (findBuildPusher.length > 0) ? findBuildPusher[0].value : 'unknown'
      parsedBuild.release = (findBuildRelease.length > 0) ? findBuildRelease[0].value : false
      parsedBuild.source.branch = (findBuildBranch.length > 0) ? findBuildBranch[0].value : 'unknown'

      // If build is complete and contains phases info, calculate total running time
      if (parsedBuild.complete && parsedBuild.info.phases) {
        // Reduce (sum) "duration-in-seconds" value from each build phase
        parsedBuild.duration = parsedBuild.info.phases.map(p => (p['duration-in-seconds'] || 0)).reduce((a, b) => a + b)
      }

      // Load link to build logs (if present)
      // [HIGH] TODO: Better way for devs to see logs? Can they be live? Can this link be created from build info (arn etc)?
      // if (parsedBuild.info.logs && parsedBuild.info.logs['deep-link']) {
      //   parsedBuild.link = parsedBuild.info.logs['deep-link']
      // }

      if (parsedBuild.arn) {
        parsedBuild.link = `https://${region}.console.aws.amazon.com/codesuite/codebuild/projects/${parsedBuild.project_name}/${parsedBuild.path}/log?region=${region}`
      }

      event.parsed.build = parsedBuild

      Build.get(parsedBuild.id).then(build => {
        if (build) {
          Object.assign(build, {
            build_status: parsedBuild.status,
            build_link: parsedBuild.link
          })

          build.saveNotify().then(resp => {
            console.log('saved build', resp, build)

            event.parsed.build.model = build
            event.parsed.build.number = build.build_number

            resolve(event)
          }).catch(reject)
        } else {
          console.warn('Unable to find build', parsedBuild)

          resolve(event)
        }
      }).catch(reject)

      // Build.scanAll({ build_arn: { eq: parsedBuild.arn } }).then(builds => {
      //   if (builds.length > 0) {
      //     const build = builds[0]

      //     Object.assign(build, {
      //       build_status: parsedBuild.status,
      //       build_link: parsedBuild.link
      //     })

      //     build.saveNotify().then(resp => {
      //       console.log('saved build', resp, build)

      //       resolve(event)
      //     }).catch(reject)
      //   } else {
      //     if (builds.length === 0) {
      //       console.warn('Unable to find build', parsedBuild)
      //     } else if (builds.length > 1) {
      //       console.warn('Found multiple builds with arn:', parsedBuild.arn)
      //     }

      //     resolve(event)
      //   }
      // }).catch(reject)

      // Set parsed build info
      // event.parsed.build = parsedBuild

      // resolve(event)
    }
  )
}

/**
 * Parse deployments from event build details
 * @param {object} event
 */
module.exports.deployments = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure the build change details have been parsed
      if (!event.parsed.build) {
        console.log('No parsed build to parse deployments from')
        resolve(event)
        return
      }

      // Init parsed deployments to empty array
      event.parsed.deployments = []

      console.log('parsed', event.parsed)

      // If parsed build has succeeded and service yaml contains deployment definitions, parse deployment details
      if (event.parsed.build.status === 'SUCCEEDED' && event.service.deployments) {
        // Tag to be deployed (from ECR repository) (prioritize release, then build number)
        let tag = event.parsed.build.release || event.parsed.build.number
        // Default deployKey to the source branch (key for deployments definition in service yaml)
        let deployKey = event.parsed.build.source.branch

        // Debug logs
        console.log('parsed build', event.parsed.build)
        // console.log('service release deployments', event.service.deployments['release'])

        console.log(`using deployKey: '${deployKey}'`)

        Object.keys(event.service.deployments).forEach(deploySource => {
          // Init deployment as null (not sure if deploying this branch/tag yet...)
          let deployment = null

          console.log(`checking deploySource: '${deploySource}'`)

          const srcRegEx = new RegExp(deploySource)

          // Test using RegExp (check if branch/ref matches key in deployments config)
          if (srcRegEx.test(deployKey)) {
            console.log('deployKey', deployKey, 'matches deploySource exact or as RegEx', srcRegEx)
            deployment = event.service.deployments[deploySource]
            console.log('using deployment', deployment)
          } else {
            console.log('NO MATCH: deployKey', deployKey, 'does not match deploySource as RegEx', srcRegEx)
          }

          // If deployment defined (and not already queued in parsed array), parse into deployment details and add to array
          if (deployment && !event.parsed.deployments.find(deploy => deploy.name === deployment.name)) {
            // Ensure targets is used as an array of strings
            const targetDeployments = Array.isArray(deployment) ? deployment : [ deployment ]

            console.log('pulling targetDeployments from deployment', targetDeployments)

            // If at least one target, parse deployment details
            if (targetDeployments.length > 0) {
              // Loop through targetDeployments
              targetDeployments.forEach(deployConfig => {
                const { name, overrides = {} } = deployConfig

                const deployInfo = {
                  tag: tag.toString(),
                  env: event.helpers.envGetAlias(name),
                  user: event.parsed.build.sender,
                  repo: event.service.source.repo,
                  rev: event.parsed.build.source.version,
                  stack: name,
                  target: event.service.target,
                  release: event.helpers.tagIsRelease(tag),
                  build_id: event.parsed.build.id,
                  deployment
                }

                const deployData = Object.assign({}, deployConfig, deployInfo, overrides)

                console.log(`Queueing deploy for '${name}' with data:`, deployData)

                // Push deployment details for current
                event.parsed.deployments.push(deployData)
              })
            }
          }
        })
      } else {
        // Debug logs
        console.log('Not deploying build:', event.parsed.build)
      }

      resolve(event)
    }
  )
}

/**
 * Parse info from a Slack slash command event
 */
module.exports.slackCommand = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure the body has already been parsed and contains a command
      if (!event.parsed.body || !event.parsed.body.command) {
        reject(new Error('Event has no parsed body and/or command.'))
        return
      }

      // Ensure the parsed body contains Slack team info
      if (!event.parsed.body.team_id || !event.parsed.body.team_domain) {
        reject(new Error('Command event is missing team information'))
        return
      }

      // Ensure the parsed body contains Slack channel info
      if (!event.parsed.body.channel_id || !event.parsed.body.channel_name) {
        reject(new Error('Command event is missing channel information.'))
        return
      }

      // Ensure the parsed body contains Slack user info
      if (!event.parsed.body.user_id || !event.parsed.body.user_name) {
        reject(new Error('Command event is missing user information.'))
        return
      }

      // Create the parsed Slack command object
      event.parsed.slackCommand = {
        // Initialize args as empty array
        args: [],

        // Reference the entire parsed body as "request" for this command event
        request: event.parsed.body,

        // The actual command (string) being handled
        command: event.parsed.body.command,

        // Slack team object
        team: {
          id: event.parsed.body.team_id,
          domain: event.parsed.body.team_domain
        },

        // Slack channel object
        channel: {
          id: event.parsed.body.channel_id,
          name: event.parsed.body.channel_name
        },

        // Slack user object
        user: {
          id: event.parsed.body.user_id,
          name: event.parsed.body.user_name
        }
      }

      // If the command was sent with "text", parse the text into arguments (split on whitespace)
      if (event.parsed.body.text && event.parsed.body.text.trim() !== '') {
        event.parsed.slackCommand.args = event.parsed.body.text.replace(/\s\s+/g, ' ').split(' ')
      }

      // Resolve this promise
      resolve(event)
    }
  )
}

/**
 * Parse info from a Slack response
 */
module.exports.slackResponse = (event) => {
  // Return a promise for the promise chain
  return new Promise(
    (resolve, reject) => {
      // Ensure the payload has already been parsed
      if (!event.parsed.payload) {
        reject(new Error('Event has no parsed payload.'))
        return
      }

      event.parsed.slackResponse = event.parsed.payload

      resolve(event)
    }
  )
}