hubot-gerrit
Version:
Hubot script for Gerrit including channel subscriptions and Slack attachments
292 lines (262 loc) • 11.2 kB
text/coffeescript
# Description:
# Interact with Gerrit. (http://code.google.com/p/gerrit/)
#
# Dependencies:
#
# Configuration:
# HUBOT_GERRIT_SSH_URL
#
# Commands:
# hubot search gerrit _<query>_ - Search Gerrit for changes (limited to 3 results)
# hubot show gerrit updates for _event_ _(patchset-created|change-abandoned|change-restored|change-merged)_ - Subscribe active channel to Gerrit updates
# hubot show gerrit updates for _(project|user)_ _<update>_ - Subscribe active channel to Gerrit updates
# hubot remove gerrit updates for _(project|user|event)_ _<update>_ - Remove Gerrit update from active channel
# hubot view gerrit subscriptions - View gerrit subscriptions for active channel
#
# Notes:
# Hubot has to be running as a user who has registered a SSH key with Gerrit
#
# Authors:
# nparry, justmiles
cp = require "child_process"
url = require "url"
# Required - The SSH URL for your Gerrit server.
sshUrl = process.env.HUBOT_GERRIT_SSH_URL || ""
attachments =
queryResult: (json) -> {
"fallback": "'#{json.change.subject}' for #{json.change.project}/#{json.change.branch} by #{extractName json.change} on #{formatDate json.change.lastUpdated}",
"title": "#{json.change.project} by #{extractName json.change}",
"title_link": json.change.url,
"text": json.change.subject,
"fields": [
{
"title": "Branch",
"value": json.change.branch,
"short": true
},
{
"title": "Date",
"value": "#{formatDate json.change.lastUpdated}",
"short": true
}
]
}
events:
"patchset-created": (json) -> {
"fallback": "#{extractName json} uploaded patchset #{json.patchSet.number} of '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}",
"title": "Commit Created in #{json.change.project}",
"title_link": json.change.url,
"color": "a6a6a6",
"text": json.change.subject,
"fields": [
{
"title": "Author",
"value": "#{extractName json}",
"short": true
},
{
"title": "Branch",
"value": json.change.branch,
"short": true
}
]
}
"change-abandoned": (json) -> {
"fallback": "#{extractName json} abandoned '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}",
"title": "Commit Abandoned in #{json.change.project}",
"title_link": json.change.url,
"color": "f30020",
"text": json.change.subject,
"fields": [
{
"title": "Abandoned by",
"value": "#{extractName json}",
"short": true
},
{
"title": "Branch",
"value": json.change.branch,
"short": true
}
]
}
"change-restored": (json) -> {
"fallback": "#{extractName json} restored '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}",
"title": "Commit Restored in #{json.change.project}",
"title_link": json.change.url,
"color": "#F35A00",
"text": json.change.subject,
"fields": [
{
"title": "Restored by",
"value": "#{extractName json}",
"short": true
},
{
"title": "Branch",
"value": json.change.branch,
"short": true
}
]
}
"change-merged": (json) -> {
"fallback": "#{extractName json} merged #{json.patchSet.number} of '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}",
"title": "Commit Merged in #{json.change.project}",
"title_link": json.change.url,
"color": "#36a64f",
"text": json.change.subject,
"fields": [
{
"title": "Merged by",
"value": "#{extractName json}",
"short": true
},
{
"title": "Branch",
"value": json.change.branch,
"short": true
}
]
}
formatters =
queryResult: (json) -> "'#{json.change.subject}' for #{json.change.project}/#{json.change.branch} by #{extractName json.change} on #{formatDate json.change.lastUpdated}: #{json.change.url}"
events:
"patchset-created": (json) -> "#{extractName json} uploaded patchset #{json.patchSet.number} of '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}"
"change-abandoned": (json) -> "#{extractName json} abandoned '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}"
"change-restored": (json) -> "#{extractName json} restored '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}"
"change-merged": (json) -> "#{extractName json} merged patchset #{json.patchSet.number} of '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}"
"comment-added": (json) -> "#{extractName json} reviewed patchset #{json.patchSet.number} (#{extractReviews json}) of '#{json.change.subject}' for #{json.change.project}/#{json.change.branch}: #{json.change.url}"
"ref-updated": (json) -> "#{extractName json} updated reference #{json.refUpdate.project}/#{json.refUpdate.refName}"
formatDate = (seconds) ->
timestamp = new Date(seconds * 1000);
format = {hour: '2-digit', minute:'2-digit'}
return "#{timestamp.toLocaleDateString()} #{timestamp.toLocaleTimeString('en-US', format)}";
asSet = (array) ->
a = array.concat()
i = 0
while i < a.length
j = i + 1
while j < a.length
if a[i] == a[j]
a.splice j--, 1
++j
++i
return a
extractName = (json) ->
account = json.uploader || json.abandoner || json.restorer || json.submitter || json.author || json.owner
account?.name || account?.email || "Gerrit"
extractReviews = (json) ->
("#{a.description}=#{a.value}" for a in json.approvals).join ","
module.exports = (robot) ->
gerrit = url.parse sshUrl
gerrit.port = 22 unless gerrit.port
if gerrit.protocol != "ssh:" || gerrit.hostname == ""
robot.logger.error "Gerrit commands inactive because HUBOT_GERRIT_SSH_URL=#{gerrit.href} is not a valid SSH URL"
else
eventStreamMe robot, gerrit
robot.respond /(?:search|query)(?: me)? gerrit (.+)/i, searchMe robot, gerrit
robot.respond /(show)(?: me)? gerrit updates for (project|user|event) (.+)/i, subscribeToEvents robot
robot.respond /(remove)(?: me)? gerrit updates for (project|user|event) (.+)/i, deleteSubscription robot
robot.respond /view gerrit subscriptions/i, showSubscriptions robot
searchMe = (robot, gerrit) -> (msg) ->
cp.exec "ssh #{gerrit.hostname} -p #{gerrit.port} gerrit query --format=JSON -- #{msg.match[1]} limit:3", (err, stdout, stderr) ->
if err
msg.send "Sorry, something went wrong talking with Gerrit: ```#{stderr}```"
else
results = (JSON.parse l for l in stdout.split "\n" when l isnt "")
status = results[results.length - 1]
if status.type == "error"
msg.send "Sorry, Gerrit didn't like your query: ```#{status.message}```"
else if status.rowCount == 0
msg.send "Gerrit didn't find anything matching your query"
else
if robot.adapterName == 'slack'
for r in results when r.id
robot.emit 'slack-attachment',
message:
room: msg.message.room
content: attachments.queryResult change: r
else
msg.send formatters.queryResult change: r for r in results when r.id
showSubscriptions = (robot) -> (msg) ->
hasSubscriptions = false
show = (obj, type) ->
for k,v of obj
if typeof v == 'object'
if v.indexOf(msg.message.room) != -1
hasSubscriptions = true
msg.send "#{msg.message.room} is subscribed to #{type} `#{k}`"
show robot.brain.data.gerrit?.eventStream?.subscription?.user, 'user'
show robot.brain.data.gerrit?.eventStream?.subscription?.event, 'event'
show robot.brain.data.gerrit?.eventStream?.subscription?.project, 'project'
if !hasSubscriptions
msg.send "#{msg.message.room} has no subscriptions"
deleteSubscription = (robot) -> (msg) ->
type = msg.match[2].toLowerCase()
event = msg.match[3]
subscribers = robot.brain.data.gerrit?.eventStream?.subscription?[type]?[event] || []
index = subscribers.indexOf(msg.message.room)
if index != -1
subscribers.splice(index, 1);
robot.brain.data.gerrit?.eventStream?.subscription?[type]?[event] = subscribers
msg.send "#{msg.message.room} will no longer receive #{event} updates."
else
msg.send "#{msg.message.room} was never subscribed to #{event} updates"
subscribeToEvents = (robot) -> (msg) ->
type = msg.match[2].toLowerCase()
event = msg.match[3]
subscribers = robot.brain.data.gerrit?.eventStream?.subscription?[type]?[event] || []
subscribers.push msg.message.room
robot.brain.data.gerrit ?= { }
robot.brain.data.gerrit.eventStream ?= { }
robot.brain.data.gerrit.eventStream.subscription ?= { }
robot.brain.data.gerrit.eventStream.subscription[type] ?= { }
robot.brain.data.gerrit.eventStream.subscription[type][event] = asSet subscribers
msg.send "This channel has subscribed to Gerrit #{type} updates matching `#{event}`"
eventStreamMe = (robot, gerrit) ->
robot.logger.info "Gerrit stream-events: Starting connection"
streamEvents = cp.spawn "ssh", [gerrit.hostname, "-p", gerrit.port, "gerrit", "stream-events"]
done = false
reconnect = null
robot.brain.on "close", ->
done = true
clearTimeout reconnect if reconnect
streamEvents.stdin.end()
streamEvents.on "exit", (code) ->
robot.logger.info "Gerrit stream-events: Connection lost (rc=#{code})"
reconnect = setTimeout (-> eventStreamMe robot, gerrit), 10 * 1000 unless done
getSubscribers = (robot, event) ->
projectSubs = robot.brain.data.gerrit?.eventStream?.subscription?.project?[event.change.project] || []
eventSubs = robot.brain.data.gerrit?.eventStream?.subscription?.event?[event.type] || []
userSubs = robot.brain.data.gerrit?.eventStream?.subscription?.user?["#{extractName event}"] || []
return asSet projectSubs.concat(eventSubs).concat(userSubs)
streamEvents.stderr.on "data", (data) ->
robot.logger.info "Gerrit stream-events: #{data}"
streamEvents.stdout.on "data", (data) ->
robot.logger.debug "Gerrit stream-events: #{data}"
json = try
JSON.parse data
catch error
robot.logger.error "Gerrit stream-events: Error parsing Gerrit JSON. Error=#{error}, Event=#{data}"
null
return unless json
if robot.adapterName == 'slack'
formatter = attachments.events[json.type]
else
formatter = formatters.events[json.type]
msg = try
formatter json if formatter
catch error
robot.logger.error "Gerrit stream-events: Error formatting event. Error=#{error}, Event=#{data}"
null
if formatter == null
robot.logger.info "Gerrit stream-events: Unrecognized event #{data}"
else if msg
for room in getSubscribers(robot, json)
if robot.adapterName == 'slack'
robot.emit 'slack-attachment',
message:
room: room
content: msg
else
robot.send room: room, "Gerrit: #{msg}"