2 # webhook plugin -- webservice plugin to support webhooks from common repository services
3 # (e.g. GitHub, GitLab, Gitea) and announce changes on IRC
4 # Most of the processing is done through two (sets of) filters:
5 # * webhook host filters take the JSON sent from the hosting server,
6 # and extract pertinent information (repository name, commit author, etc)
7 # * webhook output filters take the DataStream produced by the webhook host filter,
8 # and turn it into an IRC message to be sent by PRIVMSG or NOTICE based on the
9 # webhook.announce_method configuration
10 # The reason for this two-tier filtering is to allow the same output filters
11 # to be fed data from different (potentially unknown) hosting services.
13 # TODO for the repo matchers in the built-in filters we might want to support
14 # both the whole user/repo or just the repo name
18 class WebHookPlugin < Plugin
21 Config.register Config::EnumValue.new('webhook.announce_method',
22 :values => ['say', 'notice'],
24 :desc => "Whether to send a message or notice when announcing new GitHub actions.")
26 # Auxiliary method used to collect two lines for output filters,
27 # running substitutions against DataStream _s_ optionally joined
30 # TODO this was ripped from rss.rb considering moving it to the DataStream
31 # interface or something like that
33 # For substitutions, *_wrap keys can be used to alter the content of
34 # other nonempty keys. If the value of *_wrap is a String, it will be
35 # put before and after the corresponding key; if it's an Array, the first
36 # and second elements will be used for wrapping; if it's nil, no wrapping
37 # will be done (useful to override a default wrapping).
40 # :handle_wrap => '::'::
41 # will wrap s[:handle] by prefixing and postfixing it with '::'
42 # :date_wrap => [nil, ' :: ']::
43 # will put ' :: ' after s[:date]
44 def make_stream(line1, line2, s, h={})
49 kk = k.to_s.chomp!('_wrap')
54 wraps[nk] = ss[nk].wrap_nonempty(v, v)
56 wraps[nk] = ss[nk].wrap_nonempty(*v)
60 warning "ignoring #{v.inspect} wrapping of unknown class"
61 end unless ss[nk].nil?
67 DataStream.new([line1, line2].compact.join("\n") % subs, ss)
71 # Auxiliary method used to define rss output filters
72 def webhook_host_filter(key, &block)
73 @bot.register_filter(key, @hostkey, &block)
76 def webhook_out_filter(key, &block)
77 @bot.register_filter(key, @outkey, &block)
80 # Define the default webhook host and output filters, and load custom ones.
81 # Custom filters are looked for in the plugin's default filter locations,
82 # and in webhook/filters.rb
84 # Preferably, the webhook_host_filter and webhook_out_filter methods should be used in these files, e.g.:
85 # webhook_filter :my_output do |s|
86 # line1 = "%{repo} and some %{author} info"
87 # make_stream(line1, nil, s)
89 # to define the new filter 'my_output'.
91 # The datastream passed as input to the host filters has two keys:
93 # the hash representing the JSON payload
95 # the HTTPRequest that carried the JSON payload
97 # the expected name of the repository.
99 # Host filters should check that the request+payload is compatible with the format they expect,
100 # and that the detected repo name matches the provided one. If either condition is not satisfied,
101 # they should return nil. Otherwise, they should agument the input hash with
102 # approrpiate keys extracting the relevant information (as indicated below).
104 # The default host and out filters produce and expect the following keys in the DataStream:
106 # the event type, as described by e.g. the X-GitHub-Event request header
108 # the main event-specific object key (e.g. issue in the case of issue_comment)
110 # the hash representing the JSON payload
112 # the full name of the repository (e.g. "ruby-rbot/rbot")
114 # the sender login (e.g. "Oblomov")
118 # the ref referenced by the event
120 # the cooked number of the issue or PR modified, or the number of commits; this includes the name of the object or the word 'commits'
122 # title of the object
126 @hostkey ||= :"webhook.host"
127 @outkey ||= :"webhook.out"
129 # the default output filter
130 webhook_out_filter :default do |s|
131 line1 = "%{repo}: %{author} %{action}"
132 [:number, :title, :ref, :link].each do |k|
133 line1 += "%{#{k}}" if s[k]
135 make_stream(line1, nil, s,
136 :repo_wrap => [Irc.color(:yellow), NormalText],
137 :author_wrap => Bold,
138 :number_wrap => [' ', ''],
139 :title_wrap => [" #{Irc.color(:green)}", NormalText],
140 :ref_wrap => [" (#{Irc.color(:yellow)}", "#{NormalText})"],
141 :link_wrap => [" <#{Irc.color(:aqualight)}", "#{NormalText}>"])
144 # the github host filter is actually implemented below
145 webhook_host_filter :github do |s|
146 github_host_filter(s)
149 # gitea is essentially compatible with github
150 webhook_host_filter :gitea do |s|
151 github_host_filter(s)
154 # gitlab has a different one
155 webhook_host_filter :gitlab do |s|
156 gitlab_host_filter(s)
159 @user_types ||= datafile 'filters.rb'
161 load_filters :path => @user_types
164 # Map the event name to the payload key storing the essential information
170 # Host filters should return nil if they cannot process the given payload+request pair
171 def github_host_filter(input_stream)
172 request = input_stream[:request]
173 json = input_stream[:payload]
174 req_repo = input_stream[:repo]
176 return nil unless request['x-github-event']
178 repo = json[:repository]
179 return nil unless repo
180 repo = repo[:full_name]
181 return nil unless repo
183 return nil unless repo == req_repo
185 event = request.header['x-github-event'].first.to_sym
191 event_key = GITHUB_EVENT_KEY[event] || event
193 # :issue_comment needs special handling because it has two primary objects
194 # (the issue and the comment), and we take stuff from both
195 obj = json[event_key] || json[:issue]
197 link = json[:comment][:html_url] rescue nil if event == :issue_comment
198 link ||= obj[:html_url] || obj[:url]
201 link = json[:html_url] || json[:url] || json[:compare]
203 title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil
205 stream_hash = { :event => event,
206 :event_key => event_key,
208 :author => (json[:sender][:login] rescue nil),
209 :action => json[:action] || event,
214 num = json[:number] || obj[:number] rescue nil
215 stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => event_key.to_s.gsub('_', ' ') } if num
216 num = json[:size] || json[:commits].size rescue nil
217 stream_hash[:number] = _("%{num} commits") % { :num => num } if num
221 return input_stream.merge stream_hash
224 GITLAB_EVENT_ACTION = {
226 'tag_push' => 'pushed tag',
227 'note' => 'commented on'
230 def gitlab_host_filter(input_stream)
231 request = input_stream[:request]
232 json = input_stream[:payload]
233 req_repo = input_stream[:repo]
235 return nil unless request['x-gitlab-event']
237 repo = json[:project]
238 return nil unless repo
239 repo = repo[:path_with_namespace]
240 return nil unless repo
242 return nil unless repo == req_repo
244 event = json[:object_kind]
246 debug "No object_kind found in JSON"
250 event_key = :object_attributes
251 obj = json[event_key]
253 user = json[:user] # may be nil: some events use keys such as user_username
254 # TODO we might want to unify this at the rbot level
256 # comments have a noteable_type, but this is not the key of the object used
257 # so instead we just look for the known keys
259 [:commit, :merge_request, :issue, :snippet].each do |k|
266 link = obj ? obj[:url] : nil
267 title = notable ? notable[:title] : obj ? obj[:title] : nil
268 title ||= json[:commits].last[:title] rescue nil
270 # TODO https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
272 stream_hash = { :event => event,
273 :event_key => event_key,
275 :author => user ? user[:username] : json[:user_username],
276 :action => GITLAB_EVENT_ACTION[event] || (obj ? (obj[:action] || 'created') : event),
279 :text => obj ? (obj[:note] || obj[:description]) : nil
282 num = notable ? (notable[:iid] || notable[:id]) : obj ? obj[:iid] || obj[:id] : nil
283 stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => (obj[:noteable_type] || event).to_s.gsub('_', ' ') } if num
284 num = json[:total_commits_count]
285 stream_hash[:number] = _("%{num} commits") % { :num => num } if num
288 return input_stream.merge stream_hash
295 # @repos is hash the maps each reapo to a hash of watchers
298 if @registry.has_key?(:repos)
299 @repos = @registry[:repos]
308 @registry[:repos] = Hash.new.merge @repos
311 def help(plugin, topic="")
314 ["webhook watch #{Bold}repository#{Bold} #{Bold}filter#{Bold} [in #{Bold}\#channel#{Bold}]: announce webhook triggers matching the given repository, using the given output filter.",
315 "the repository should be defined as service:name where service is known service, and name the actual repository name.",
316 "example: webhook watch github:ruby-rbot/rbot github"].join("\n")
318 " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
320 " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
324 def watch_repo(m, params)
326 chan = (params[:chan] || m.replyto).downcase
327 filter = params[:filter] || :default
330 @repos[repo][chan] = filter
334 def unwatch_repo(m, params)
336 chan = (params[:chan] || m.replyto).downcase
338 if @repo.has_key?(repo)
339 @repos[repo].delete(chan)
341 if @repos[repo].empty?
343 m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
346 m.reply _("repo %{repo} not found") % params
350 # Display the host filters
351 def list_host_filters(m, params)
352 ar = @bot.filter_names(@hostkey)
354 m.reply _("No custom service filters registered")
356 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
360 # Display the known output filters
361 def list_output_filters(m, params)
362 ar = @bot.filter_names(@outkey)
365 m.reply _("No custom output filters registered")
367 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
371 # Display the known repos and watchers
372 def list_repos(m, params)
374 m.reply "No repos defined"
377 msg = @repos.map do |repo, watchers|
378 [Bold + repo + Bold, watchers.map do |channel, filter|
379 "#{channel} (#{filter})"
380 end.join(", ")].join(": ")
385 def filter_hook(json, request)
386 announce_method = @bot.config['webhook.announce_method']
391 @repos.each do |s_repo, watchers|
392 host, repo = s_repo.split(':', 2)
393 key = @bot.global_filter_name(host, @hostkey)
394 error "No host filter for #{host} (from #{s_repo})" unless @bot.has_filter?(key)
397 processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
399 next unless processed
401 # TODO if we see that the same output filter is applied to multiple channels,
402 # we should group the channels by filters and only do the output processing once
403 watchers.each do |channel, filter|
405 key = @bot.global_filter_name(filter, @outkey)
406 key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
409 output = @bot.filter(key, processed)
412 @bot.__send__(announce_method, channel, output)
414 error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
416 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
419 # match found, stop checking
424 def process_hook(m, params)
427 json = JSON.parse(m.req.body, :symbolize_names => true)
429 error "Failed to parse request #{m.req}"
432 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
435 # Send the response early
437 m.send_plaintext("Failed\n", 400)
441 m.send_plaintext("OK\n", 200)
444 filter_hook(json, m.req)
448 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
454 plugin = WebHookPlugin.new
455 plugin.web_map "/webhook", :action => :process_hook
457 plugin.map 'webhook watch :repo :filter [in :chan]',
458 :action => :watch_repo,
459 :defaults => { :filter => nil }
461 plugin.map 'webhook unwatch :repo [in :chan]',
462 :action => :unwatch_repo
464 plugin.map 'webhook list [repos]',
465 :action => 'list_repos'
467 plugin.map 'webhook [list] filters',
468 :action => 'list_output_filters'
470 plugin.map 'webhook [list] hosts',
471 :action => 'list_host_filters'
473 plugin.map 'webhook [list] services',
474 :action => 'list_host_filters'