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
16 # TODO specialized output filter by event/event_key, with some kind of automatic selection
17 # e.g. if :default_pull_request exists, then it's automatically used if :event => "pull_request"
18 # and :default is the current output filter.
19 # The big question is what we should fallback to if the specific filter doesn't exist..
21 # If :custom exists, :default_pull_request exists and :custom_pull_request does not,
22 # should we fall back to :custom or to :default_pull_request?
27 class WebHookPlugin < Plugin
30 Config.register Config::EnumValue.new('webhook.announce_method',
31 :values => ['say', 'notice'],
33 :desc => "Whether to send a message or notice when announcing new GitHub actions.")
35 # Auxiliary method used to collect two lines for output filters,
36 # running substitutions against DataStream _s_ optionally joined
39 # TODO this was ripped from rss.rb considering moving it to the DataStream
40 # interface or something like that
42 # For substitutions, *_wrap keys can be used to alter the content of
43 # other nonempty keys. If the value of *_wrap is a String, it will be
44 # put before and after the corresponding key; if it's an Array, the first
45 # and second elements will be used for wrapping; if it's nil, no wrapping
46 # will be done (useful to override a default wrapping).
49 # :handle_wrap => '::'::
50 # will wrap s[:handle] by prefixing and postfixing it with '::'
51 # :date_wrap => [nil, ' :: ']::
52 # will put ' :: ' after s[:date]
53 def make_stream(line1, line2, s, h={})
58 kk = k.to_s.chomp!('_wrap')
63 wraps[nk] = ss[nk].wrap_nonempty(v, v)
65 wraps[nk] = ss[nk].wrap_nonempty(*v)
69 warning "ignoring #{v.inspect} wrapping of unknown class"
70 end unless ss[nk].nil?
76 DataStream.new([line1, line2].compact.join("\n") % subs, ss)
80 # Auxiliary method used to define rss output filters
81 def webhook_host_filter(key, &block)
82 @bot.register_filter(key, @hostkey, &block)
85 def webhook_out_filter(key, &block)
86 @bot.register_filter(key, @outkey, &block)
89 # Define the default webhook host and output filters, and load custom ones.
90 # Custom filters are looked for in the plugin's default filter locations,
91 # and in webhook/filters.rb
93 # Preferably, the webhook_host_filter and webhook_out_filter methods should be used in these files, e.g.:
94 # webhook_filter :my_output do |s|
95 # line1 = "%{repo} and some %{author} info"
96 # make_stream(line1, nil, s)
98 # to define the new filter 'my_output'.
100 # The datastream passed as input to the host filters has two keys:
102 # the hash representing the JSON payload
104 # the HTTPRequest that carried the JSON payload
106 # the expected name of the repository.
108 # Host filters should check that the request+payload is compatible with the format they expect,
109 # and that the detected repo name matches the provided one. If either condition is not satisfied,
110 # they should return nil. Otherwise, they should agument the input hash with
111 # approrpiate keys extracting the relevant information (as indicated below).
113 # The default host and out filters produce and expect the following keys in the DataStream:
115 # the event type, as described by e.g. the X-GitHub-Event request header
117 # the main event-specific object key (e.g. issue in the case of issue_comment)
119 # the hash representing the JSON payload
121 # the full name of the repository (e.g. "ruby-rbot/rbot")
123 # the sender login (e.g. "Oblomov")
127 # the ref referenced by the event
129 # 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'
131 # title of the object
135 @hostkey ||= :"webhook.host"
136 @outkey ||= :"webhook.out"
138 # the default output filter
139 webhook_out_filter :default do |s|
140 line1 = "%{repo}: %{author} %{action}"
141 [:number, :title, :ref, :link].each do |k|
142 line1 += "%{#{k}}" if s[k]
144 make_stream(line1, nil, s,
145 :repo_wrap => [Irc.color(:yellow), NormalText],
146 :author_wrap => Bold,
147 :number_wrap => [' ', ''],
148 :title_wrap => [" #{Irc.color(:green)}", NormalText],
149 :ref_wrap => [" (#{Irc.color(:yellow)}", "#{NormalText})"],
150 :link_wrap => [" <#{Irc.color(:aqualight)}", "#{NormalText}>"])
153 # the github host filter is actually implemented below
154 webhook_host_filter :github do |s|
155 github_host_filter(s)
158 # gitea is essentially compatible with github
159 webhook_host_filter :gitea do |s|
160 github_host_filter(s)
163 # gitlab has a different one
164 webhook_host_filter :gitlab do |s|
165 gitlab_host_filter(s)
168 @user_types ||= datafile 'filters.rb'
170 load_filters :path => @user_types
173 # Map the event name to the payload key storing the essential information
179 # Host filters should return nil if they cannot process the given payload+request pair
180 def github_host_filter(input_stream)
181 request = input_stream[:request]
182 json = input_stream[:payload]
183 req_repo = input_stream[:repo]
185 return nil unless request['x-github-event']
187 repo = json[:repository]
188 return nil unless repo
189 repo = repo[:full_name]
190 return nil unless repo
192 return nil unless repo == req_repo
194 event = request.header['x-github-event'].first.to_sym
200 event_key = GITHUB_EVENT_KEY[event] || event
202 # :issue_comment needs special handling because it has two primary objects
203 # (the issue and the comment), and we take stuff from both
204 obj = json[event_key] || json[:issue]
206 link = json[:comment][:html_url] rescue nil if event == :issue_comment
207 link ||= obj[:html_url] || obj[:url]
210 link = json[:html_url] || json[:url] || json[:compare]
212 title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil
214 stream_hash = { :event => event,
215 :event_key => event_key,
217 :author => (json[:sender][:login] rescue nil),
218 :action => json[:action] || event,
223 stream_hash[:ref] ||= json[:base][:ref] if json[:base]
225 num = json[:number] || obj[:number] rescue nil
226 stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => event_key.to_s.gsub('_', ' ') } if num
227 num = json[:size] || json[:commits].size rescue nil
228 stream_hash[:number] = _("%{num} commits") % { :num => num } if num
232 return input_stream.merge stream_hash
235 GITLAB_EVENT_ACTION = {
237 'tag_push' => 'pushed tag',
238 'note' => 'commented on'
241 def gitlab_host_filter(input_stream)
242 request = input_stream[:request]
243 json = input_stream[:payload]
244 req_repo = input_stream[:repo]
246 return nil unless request['x-gitlab-event']
248 repo = json[:project]
249 return nil unless repo
250 repo = repo[:path_with_namespace]
251 return nil unless repo
253 return nil unless repo == req_repo
255 event = json[:object_kind]
257 debug "No object_kind found in JSON"
261 event_key = :object_attributes
262 obj = json[event_key]
264 user = json[:user] # may be nil: some events use keys such as user_username
265 # TODO we might want to unify this at the rbot level
267 # comments have a noteable_type, but this is not the key of the object used
268 # so instead we just look for the known keys
270 [:commit, :merge_request, :issue, :snippet].each do |k|
277 link = obj ? obj[:url] : nil
278 title = notable ? notable[:title] : obj ? obj[:title] : nil
279 title ||= json[:commits].last[:title] rescue nil
281 # TODO https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
283 stream_hash = { :event => event,
284 :event_key => event_key,
286 :author => user ? user[:username] : json[:user_username],
287 :action => GITLAB_EVENT_ACTION[event] || (obj ? (obj[:action] || 'created') : event),
290 :text => obj ? (obj[:note] || obj[:description]) : nil
293 stream_hash[:ref] ||= obj[:target_branch] if obj
295 num = notable ? (notable[:iid] || notable[:id]) : obj ? obj[:iid] || obj[:id] : nil
296 stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => (obj[:noteable_type] || event).to_s.gsub('_', ' ') } if num
297 num = json[:total_commits_count]
298 stream_hash[:number] = _("%{num} commits") % { :num => num } if num
301 return input_stream.merge stream_hash
308 # @repos is hash the maps each reapo to a hash of watchers
311 if @registry.has_key?(:repos)
312 @repos = @registry[:repos]
321 @registry[:repos] = Hash.new.merge @repos
324 def help(plugin, topic="")
327 ["webhook watch #{Bold}repository#{Bold} #{Bold}filter#{Bold} [in #{Bold}\#channel#{Bold}]: announce webhook triggers matching the given repository, using the given output filter.",
328 "the repository should be defined as service:name where service is known service, and name the actual repository name.",
329 "example: webhook watch github:ruby-rbot/rbot github"].join("\n")
331 " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
333 " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
337 def watch_repo(m, params)
339 chan = (params[:chan] || m.replyto).downcase
340 filter = params[:filter] || :default
343 @repos[repo][chan] = filter
347 def unwatch_repo(m, params)
349 chan = (params[:chan] || m.replyto).downcase
351 if @repos.has_key?(repo)
352 @repos[repo].delete(chan)
354 if @repos[repo].empty?
356 m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
359 m.reply _("repo %{repo} not found") % params
363 # Display the host filters
364 def list_host_filters(m, params)
365 ar = @bot.filter_names(@hostkey)
367 m.reply _("No custom service filters registered")
369 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
373 # Display the known output filters
374 def list_output_filters(m, params)
375 ar = @bot.filter_names(@outkey)
378 m.reply _("No custom output filters registered")
380 m.reply ar.map { |k| k.to_s }.sort!.join(", ")
384 # Display the known repos and watchers
385 def list_repos(m, params)
387 m.reply "No repos defined"
390 msg = @repos.map do |repo, watchers|
391 [Bold + repo + Bold, watchers.map do |channel, filter|
392 "#{channel} (#{filter})"
393 end.join(", ")].join(": ")
398 def filter_hook(json, request)
399 announce_method = @bot.config['webhook.announce_method']
404 @repos.each do |s_repo, watchers|
405 host, repo = s_repo.split(':', 2)
406 key = @bot.global_filter_name(host, @hostkey)
407 error "No host filter for #{host} (from #{s_repo})" unless @bot.has_filter?(key)
410 processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
412 next unless processed
414 # TODO if we see that the same output filter is applied to multiple channels,
415 # we should group the channels by filters and only do the output processing once
416 watchers.each do |channel, filter|
418 key = @bot.global_filter_name(filter, @outkey)
419 key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
422 output = @bot.filter(key, processed)
425 @bot.__send__(announce_method, channel, output)
427 error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
429 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
432 # match found, stop checking
437 def process_hook(m, params)
440 json = JSON.parse(m.req.body, :symbolize_names => true)
442 error "Failed to parse request #{m.req}"
445 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
448 # Send the response early
450 m.send_plaintext("Failed\n", 400)
454 m.send_plaintext("OK\n", 200)
457 filter_hook(json, m.req)
461 debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
467 plugin = WebHookPlugin.new
468 plugin.web_map "/webhook", :action => :process_hook
470 plugin.map 'webhook watch :repo :filter [in :chan]',
471 :action => :watch_repo,
472 :defaults => { :filter => nil }
474 plugin.map 'webhook unwatch :repo [in :chan]',
475 :action => :unwatch_repo
477 plugin.map 'webhook list [repos]',
478 :action => 'list_repos'
480 plugin.map 'webhook [list] filters',
481 :action => 'list_output_filters'
483 plugin.map 'webhook [list] hosts',
484 :action => 'list_host_filters'
486 plugin.map 'webhook [list] services',
487 :action => 'list_host_filters'