diff --git a/README.md b/README.md index 75b9594d..552223f8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Then add **hubot-pager-me** to your `external-scripts.json`: | `HUBOT_PAGERDUTY_USER_ID` | No`*` | The user ID of a PagerDuty user for your bot. This is only required if you want chat users to be able to trigger incidents without their own PagerDuty user. | `HUBOT_PAGERDUTY_SERVICE_API_KEY` | No`*` | The [Incident Service Key](https://v2.developer.pagerduty.com/docs/incident-creation-api) to use when creating a new incident. This should be assigned to a dummy escalation policy that doesn't actually notify, as Hubot will trigger on this before reassigning it. | `HUBOT_PAGERDUTY_SERVICES` | No | Provide a comma separated list of service identifiers (e.g. `PFGPBFY,AFBCGH`) to restrict queries to only those services. | +| `HUBOT_PAGERDUTY_TEAMS` | No | Provide a comma separated list of teams identifiers (e.g. `PFGPBFY,AFBCGH`) to restrict queries to only those teams. You need teams function on | | `HUBOT_PAGERDUTY_SCHEDULES` | No | Provide a comma separated list of schedules identifiers (e.g. `PFGPBFY,AFBCGH`) to restrict queries to only those schedules. | `*` - May be required for certain actions. diff --git a/package-lock.json b/package-lock.json index 8335e08c..21345fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2754,10 +2754,9 @@ } }, "lodash": { - "version": "4.17.19", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "lodash.find": { "version": "4.6.0", @@ -3084,9 +3083,9 @@ } }, "node-fetch": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", "dev": true }, "normalize-path": { @@ -3859,6 +3858,12 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 41c2dfdb..5a1e862a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "async": "^3.2.0", + "lodash": "^4.17.20", "moment-timezone": "^0.5.31", "scoped-http-client": "^0.11.0" }, diff --git a/src/pagerduty.coffee b/src/pagerduty.coffee index ae031b72..e8d7afa2 100644 --- a/src/pagerduty.coffee +++ b/src/pagerduty.coffee @@ -1,9 +1,14 @@ HttpClient = require 'scoped-http-client' +_ = require('lodash') +moment = require('moment-timezone') +timezone = 'UTC' pagerDutyApiKey = process.env.HUBOT_PAGERDUTY_API_KEY pagerDutySubdomain = process.env.HUBOT_PAGERDUTY_SUBDOMAIN pagerDutyBaseUrl = 'https://api.pagerduty.com' pagerDutyServices = process.env.HUBOT_PAGERDUTY_SERVICES +pagerDutyTeams = process.env.HUBOT_PAGERDUTY_TEAMS +pagerDutySchedules = process.env.HUBOT_PAGERDUTY_SCHEDULES pagerDutyFromEmail = process.env.HUBOT_PAGERDUTY_FROM_EMAIL pagerNoop = process.env.HUBOT_PAGERDUTY_NOOP pagerNoop = false if pagerNoop is 'false' or pagerNoop is 'off' @@ -33,8 +38,11 @@ module.exports = cb = query query = {} + if pagerDutyTeams? && url.match /\/incidents/ + query['teams_ids[]'] = pagerDutyTeams.split(',') + if pagerDutyServices? && url.match /\/incidents/ - query['service_id'] = pagerDutyServices + query['service_ids[]'] = pagerDutyServices.split(',') @http(url) .query(query) @@ -134,6 +142,38 @@ module.exports = return cb(null, json.incidents) + getOncalls: (query, cb) -> + if typeof(query) is 'function' + cb = query + query = {} + + if pagerDutySchedules? + query['schedule_ids[]'] = pagerDutySchedules.split(',') + + if pagerDutyEscalationsPolicies? + query['escalation_policy_ids[]'] = pagerDutyEscalationsPolicies.split(',') + + console.error query + + @get "/oncalls", query, (err, json) -> + if err? + cb(err) + return + # escalation_level filtering + oncalls = _.map json.oncalls, (o) -> + if o.escalation_level is 1 then return o + filterdOncalls = _.without(oncalls, undefined) + + oncallsBySchedules = _.transform(filterdOncalls, (result, value, key) -> + message = "(#{moment(value.start).tz(timezone).format('MMM Do, h:mm a')} - #{moment(value.end).tz(timezone).format('MMM Do, h:mm a')}) - *#{value.user.summary}*" + unless result[value.schedule.summary] + (result[value.schedule.summary] || (result[value.schedule.summary] = [])).push(message); + if result[value.schedule.summary].indexOf(message) == -1 + (result[value.schedule.summary] || (result[value.schedule.summary] = [])).push(message); + , {}) + + cb(null, oncallsBySchedules) + getSchedules: (query, cb) -> if typeof(query) is 'function' cb = query diff --git a/src/scripts/pagerduty-custom.coffee b/src/scripts/pagerduty-custom.coffee new file mode 100644 index 00000000..070b131c --- /dev/null +++ b/src/scripts/pagerduty-custom.coffee @@ -0,0 +1,77 @@ +pagerduty = require('../pagerduty') +userSupportId = process.env.HUBOT_PAGERDUTY_SCHEDULE_USERSUP_ID +platformId = process.env.HUBOT_PAGERDUTY_SCHEDULE_PLATFORM_ID +escalationId = process.env.HUBOT_PAGERDUTY_SCHEDULE_ESCALATION_ID + +# selects the right oncall based on current time +findOncall = (oncalls, timeFrame, timeNow) -> + if timeFrame is 'was' + return oncalls.find((oncall) -> + Date.parse(oncall.start) < timeNow - (24 * 3600000) < Date.parse(oncall.end) + ) + if timeFrame is 'next' + return oncalls.find((oncall) -> + Date.parse(oncall.start) > timeNow + ) + return oncalls.find((oncall) -> + Date.parse(oncall.start) < timeNow < Date.parse(oncall.end) + ) + +# formats time to show the name of the day, month, day and time +formatTime = (date) -> + dateTime = new Date(date).toString() + return "#{dateTime.substring(0, 10)} #{dateTime.substring(16, 21)}" + +# according to requesed time (on call, now, on call next, was on call) prepares time query +# pagerduty limits the amount of returned data, so more precise time settings +setTimeQuery = (timeFrame, timeNow) -> + past = new Date(timeNow - (72 * 3600000)).toISOString() + future = new Date(timeNow + (72 * 3600000)).toISOString() + plusMinute = new Date(timeNow + 60000).toISOString() + + if timeFrame is 'was' + return { since: past, untilParam: new Date(timeNow).toISOString() } + + if timeFrame is 'next' + return { since: new Date(timeNow).toISOString(), untilParam: future } + + return { since: new Date(timeNow).toISOString(), untilParam: plusMinute } + +getCustomOncalls = (timeFrame, msg) -> + if not msg? + console.log('no msg sent') + return + + timeNow = Date.now() + timeQuery = setTimeQuery(timeFrame, timeNow) + query = { + limit: 50 + time_zone: 'UTC' + "schedule_ids[]": [userSupportId, platformId, escalationId], + since: timeQuery.since + until: timeQuery.untilParam + } + + pagerduty.get('/oncalls', query, (err, json) -> + if err + msg.send(err) + + userSupports = json.oncalls.filter((oncall) -> oncall.schedule.id is userSupportId) + escallations = json.oncalls.filter((oncall) -> oncall.schedule.id is escalationId) + platformOncalls = json.oncalls.filter((oncall) -> oncall.schedule.id is platformId) + + userSupport = findOncall(userSupports, timeFrame, timeNow) + escallation = findOncall(escallations, timeFrame, timeNow) + platformOncall = findOncall(platformOncalls, timeFrame, timeNow) + + message = "#{userSupport.schedule.summary} - (#{formatTime(userSupport.start)} - #{formatTime(userSupport.end)}) - *#{userSupport.user.summary}*\n" + message += "#{platformOncall.schedule.summary} - #{formatTime(platformOncall.start)} - #{formatTime(platformOncall.end)} - *#{platformOncall.user.summary}*\n" + message += "#{escallation.schedule.summary} - #{formatTime(escallation.start)} - #{formatTime(escallation.end)} - *#{escallation.user.summary}*\n" + + msg.send(message) + ) +getCustomOncalls.findOncall = findOncall +getCustomOncalls.formatTime = formatTime +getCustomOncalls.setTimeQuery = setTimeQuery + +module.exports = getCustomOncalls \ No newline at end of file diff --git a/src/scripts/pagerduty.coffee b/src/scripts/pagerduty.coffee index bf5f206e..b94ad8b4 100644 --- a/src/scripts/pagerduty.coffee +++ b/src/scripts/pagerduty.coffee @@ -41,6 +41,8 @@ pagerduty = require('../pagerduty') async = require('async') inspect = require('util').inspect moment = require('moment-timezone') +_ = require('lodash') +getCustomOncalls = require('./pagerduty-custom.coffee') pagerDutyUserId = process.env.HUBOT_PAGERDUTY_USER_ID pagerDutyServiceApiKey = process.env.HUBOT_PAGERDUTY_SERVICE_API_KEY @@ -599,54 +601,18 @@ module.exports = (robot) -> else msg.send 'No schedules found!' - # who is on call? - robot.respond /who(?:’s|'s|s| is|se)? (?:on call|oncall|on-call)(?:\?)?(?: (?:for )?((["'])([^]*?)\2|(.*?))(?:\?|$))?$/i, (msg) -> - if pagerduty.missingEnvironmentForApi(msg) - return - - scheduleName = msg.match[3] or msg.match[4] - messages = [] - allowed_schedules = [] - if pagerDutySchedules? - allowed_schedules = pagerDutySchedules.split(",") + # who was on call? + robot.respond /who was (on call|oncall|on-call)/i, (msg) -> + getCustomOncalls 'was', msg - renderSchedule = (s, cb) -> - withCurrentOncall msg, s, (username, schedule) -> - # If there is an allowed schedules array, skip returned schedule not in it - if allowed_schedules.length and schedule.id not in allowed_schedules - robot.logger.debug "Schedule #{schedule.id} (#{schedule.name}) not in HUBOT_PAGERDUTY_SCHEDULES" - return cb null - - # Ignore schedule if no user assigned to it - if (username) - messages.push("* #{username} is on call for #{schedule.name} - #{schedule.html_url}") - else - robot.logger.debug "No user for schedule #{schedule.name}" - - # Return callback - cb null + # who is next on call? + robot.respond /who(?:’s|'s|s| is|se)? ((next (on call|oncall|on-call))|((on call|oncall|on-call) next))/i, (msg) -> + getCustomOncalls 'next', msg - if scheduleName? - SchedulesMatching msg, scheduleName, (s) -> - async.map s, renderSchedule, (err) -> - if err? - robot.emit 'error', err, msg - return - msg.send messages.join("\n") - else - pagerduty.getSchedules (err, schedules) -> - if err? - robot.emit 'error', err, msg - return - if schedules.length > 0 - async.map schedules, renderSchedule, (err) -> - if err? - robot.emit 'error', err, msg - return - msg.send messages.join("\n") - else - msg.send 'No schedules found!' + # who is on call? + robot.respond /who(?:’s|'s|s| is|se)? (?:on call|oncall|on-call)(?:\?)?(?: (?:for )?((["'])([^]*?)\2|(.*?))(?:\?|$))?$/i, (msg) -> + getCustomOncalls 'now', msg robot.respond /(pager|major)( me)? services$/i, (msg) -> if pagerduty.missingEnvironmentForApi(msg) @@ -697,10 +663,136 @@ module.exports = (robot) -> else msg.send "That didn't work. Check Hubot's logs for an error!" + + getOncalls = (msg, hoursSpan) -> + if pagerduty.missingEnvironmentForApi(msg) + return + + messages = [] + + oncallName = msg.match[3] or msg.match[4] + + if oncallName?.trim() is 'next' + return + + query = { + since: moment().format(), + until: moment().add(1, 'minute').format(), + earliest: true + } + + if hoursSpan + if hoursSpan > 0 + query.since = moment().format() + query.until = moment().add(hoursSpan, 'hours').format() + else + query.since = moment().subtract(-hoursSpan, 'hours').format() + query.until = moment().format() + + pagerduty.getOncalls query, (err, oncalls) -> + if err? + robot.emit 'error', err, msg + return + if Object.keys(oncalls).length > 0 + filteredOncalls = oncalls + _.forEach filteredOncalls, (value, key) -> + if hoursSpan < 0 + values = value[value.length - 1] + else if hoursSpan > 0 + if value.length > 1 + values = value[1] + else + values = value[0] + else + values = value.join(", ") + + messages.push("> #{key} #{values}") + msg.send _.uniq(messages).sort().join('\n') + else + msg.send 'No oncall found!' + + getFirstOncalls = (oncalls) -> + return oncalls + + getLatestOncalls = (oncalls) -> + return oncalls + + parseIncidentNumbers = (match) -> match.split(/[ ,]+/).map (incidentNumber) -> parseInt(incidentNumber) + userEmail = (user) -> + user.pagerdutyEmail || user.email_address || user.profile?.email || process.env.HUBOT_PAGERDUTY_TEST_EMAIL + + campfireUserToPagerDutyUser = (msg, user, required, cb) -> + if typeof required is 'function' + cb = required + required = true + + ## Determine the email based on the adapter type (v4.0.0+ of the Slack adapter stores it in `profile.email`) + email = userEmail(user) + speakerEmail = userEmail(msg.message.user) + + if not email + if not required + cb null + return + else + possessive = if email is speakerEmail + "your" + else + "#{user.name}'s" + addressee = if email is speakerEmail + "you" + else + "#{user.name}" + + msg.send "Sorry, I can't figure out #{possessive} email address :( Can #{addressee} tell me with `#{robot.name} pager me as you@yourdomain.com`?" + return + + pagerduty.get "/users", {query: email}, (err, json) -> + if err? + robot.emit 'error', err, msg + return + + if json.users.length isnt 1 + if json.users.length is 0 and not required + cb null + return + else + msg.send "Sorry, I expected to get 1 user back for #{email}, but got #{json.users.length} :sweat:. If your PagerDuty email is not #{email} use `/pager me as #{email}`" + return + + cb(json.users[0]) + + SchedulesMatching = (msg, q, cb) -> + query = { + query: q + } + pagerduty.getSchedules query, (err, schedules) -> + if err? + robot.emit 'error', err, msg + return + + cb(schedules) + + withScheduleMatching = (msg, q, cb) -> + SchedulesMatching msg, q, (schedules) -> + if schedules?.length < 1 + msg.send "I couldn't find any schedules matching #{q}" + else + cb(schedule) for schedule in schedules + return + + withOncallMatching = (msg, q, cb) -> + OncallsMatching msg, q, (oncalls) -> + if oncalls?.length < 1 + msg.send "I couldn't find any oncalls matching #{q}" + else + cb(oncall) for oncall in oncalls + return + reassignmentParametersForUserOrScheduleOrEscalationPolicy = (msg, string, cb) -> if campfireUser = robot.brain.userForName(string) campfireUserToPagerDutyUser msg, campfireUser, (user) -> @@ -732,6 +824,107 @@ module.exports = (robot) -> else cb() + withCurrentOncall = (msg, schedule, cb) -> + withCurrentOncallUser 1, msg, schedule, (user, s) -> + cb(user?.name or 'Nobody', s) + + withCurrentOncallId = (msg, schedule, cb) -> + withCurrentOncallUser 1, msg, schedule, (user, s) -> + cb(user.id, user.name, s) + + withCurrentOncallUser = (addedHours = 1, msg, schedule, cb) -> + if typeof schedule is 'function' + cb = schedule + schedule = msg + msg = addedHours + addedHours = 1 + + if addedHours > 0 + timeSince = moment().format() + timeUntil = moment().add(addedHours, 'hours').format() + else + timeSince = moment().subtract(-addedHours, 'hours').format() + timeUntil = moment().format() + + scheduleId = schedule.id + if (schedule instanceof Array && schedule[0]) + scheduleId = schedule[0].id + unless scheduleId + msg.send "Unable to retrieve the schedule. Use 'pager schedules' to list all schedules." + return + + query = { + since: timeSince + until: timeUntil + overflow: 'true' + } + + pagerduty.get "/schedules/#{scheduleId}/users", query, (err, json) -> + if err? + robot.emit 'error', err, msg + return + if json.entries and json.entries.length > 0 + if addedHours isnt 1 # custom hours [who (next|was) oncall] + if addedHours > 0 + first = json.entries[0] + next = json.entries[1] or first + users = [ + first.user + next.user + ] + else if addedHours < 0 + last = json.entries.pop() + prev = json.entries.pop() or last + users = [ + prev.user + last.user + ] + return cb(users, schedule) + cb(json.entries[0].user, schedule) + else + cb(null, schedule) + + + withTimeBasedOncall = (addedHours = 1, msg, cb) -> + renderSchedule = (s, cb) -> + withCurrentOncallUser addedHours, msg, s, (users, schedule = {}) -> + cb(null, + users.map((user) -> + "#{schedule.name} - *#{user.name}*" + ) + ) + + pagerduty.getSchedules (err, schedules = []) -> + if err? + robot.emit 'error', err, msg + return + + if schedules.length > 0 + async.map schedules, renderSchedule, (err, results = []) -> + if err? + robot.emit 'error', err, msg + return + rows = [] + + # Mix these arrays: + # ['platform userA', 'platform userX'] + # ['user-support userC', 'user-support userT'] + # into: + # [ + # 'platform userA and user-support userC' + # 'platform userX and user-support userT' + # ] + + for userIndex in [0..(results[0]?.length - 1)] by 1 + messageRow = [] + for historyIndex in [0..(results.length - 1)] by 1 + messageRow.push(results[historyIndex][userIndex]) + rows.push(messageRow) + cb(rows) + else + msg.send 'No schedules found!' + + pagerDutyIntegrationAPI = (msg, cmd, description, cb) -> unless pagerDutyServiceApiKey? msg.send "PagerDuty API service key is missing." diff --git a/test/pagerduty-custom-unit.coffee b/test/pagerduty-custom-unit.coffee new file mode 100644 index 00000000..942a7985 --- /dev/null +++ b/test/pagerduty-custom-unit.coffee @@ -0,0 +1,36 @@ +# module.exports = { findOncall, formatTime } +chai = require 'chai' +formatTime = require('../src/scripts/pagerduty-custom.coffee').formatTime +findOncall = require('../src/scripts/pagerduty-custom.coffee').findOncall +setTimeQuery = require('../src/scripts/pagerduty-custom.coffee').setTimeQuery + +expect = chai.expect + +oncalls = [ + {name: 'yesterday', start: "2020-11-27T17:00:00.000Z", end: "2020-11-29T17:00:00.000Z"}, + {name: 'today',start: "2020-11-29T17:00:00.000Z", end: "2020-11-30T17:00:00.000Z"}, + {name: 'tomorrow',start: "2020-11-30T17:00:00.000Z", end: "2020-12-01T17:00:00.000Z"}, +] + +timeNow = Date.parse("2020-11-30T13:00:00.000Z") + +describe 'custom on call - findOncall', -> + it 'should return today for `now` timeFrame', -> + expect(findOncall(oncalls, 'now', timeNow).name).to.equal('today') + + it 'should return yesterday for `was` timeFrame', -> + expect(findOncall(oncalls, 'was', timeNow).name).to.equal('yesterday') + + it 'should return yesterday for `next` timeFrame', -> + expect(findOncall(oncalls, 'next', timeNow).name).to.equal('tomorrow') + +describe 'custom on call - formatTime', -> + it 'should format time correctly', -> + expect(formatTime(oncalls[1].start)).to.equal('Sun Nov 29 18:00') + expect(formatTime(oncalls[0].end)).to.equal('Sun Nov 29 18:00') + expect(formatTime(oncalls[2].end)).to.equal('Tue Dec 01 18:00') + +describe 'custom on call - setTimeQuery', -> + it 'should return time query according to requested time', -> + expect(setTimeQuery('now', timeNow).since).to.equal('2020-11-30T13:00:00.000Z') + expect(setTimeQuery('now', timeNow).untilParam).to.equal('2020-11-30T13:01:00.000Z') \ No newline at end of file