]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/webhook.rb
cf93d9a732678cb28ac88f8e2df048b09150eb90
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / webhook.rb
1 # vi:et:sw=2
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.
12
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
15
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..
20 #
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?
23
24
25 require 'json'
26
27 class WebHookPlugin < Plugin
28   include WebPlugin
29
30   Config.register Config::EnumValue.new('webhook.announce_method',
31     :values => ['say', 'notice'],
32     :default => 'say',
33     :desc => "Whether to send a message or notice when announcing new GitHub actions.")
34
35   # Auxiliary method used to collect two lines for  output filters,
36   # running substitutions against DataStream _s_ optionally joined
37   # with hash _h_.
38   #
39   # TODO this was ripped from rss.rb considering moving it to the DataStream
40   # interface or something like that
41   #
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).
47   #
48   # For example:
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={})
54     ss = s.merge(h)
55     subs = {}
56     wraps = {}
57     ss.each do |k, v|
58       kk = k.to_s.chomp!('_wrap')
59       if kk
60         nk = kk.intern
61         case v
62         when String
63           wraps[nk] = ss[nk].wrap_nonempty(v, v)
64         when Array
65           wraps[nk] = ss[nk].wrap_nonempty(*v)
66         when nil
67           # do nothing
68         else
69           warning "ignoring #{v.inspect} wrapping of unknown class"
70         end unless ss[nk].nil?
71       else
72         subs[k] = v
73       end
74     end
75     subs.merge! wraps
76     DataStream.new([line1, line2].compact.join("\n") % subs, ss)
77   end
78
79
80   # Auxiliary method used to define rss output filters
81   def webhook_host_filter(key, &block)
82     @bot.register_filter(key, @hostkey, &block)
83   end
84
85   def webhook_out_filter(key, &block)
86     @bot.register_filter(key, @outkey, &block)
87   end
88
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 
92   #
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)
97   #   end
98   # to define the new filter 'my_output'.
99   #
100   # The datastream passed as input to the host filters has two keys:
101   # payload::
102   #   the hash representing the JSON payload
103   # request::
104   #   the HTTPRequest that carried the JSON payload
105   # repo::
106   #   the expected name of the repository.
107   #
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).
112   #
113   # The default host and out filters produce and expect the following keys in the DataStream:
114   # event::
115   #   the event type, as described by e.g. the X-GitHub-Event request header
116   # event_key::
117   #   the main event-specific object key (e.g. issue in the case of issue_comment)
118   # payload::
119   #   the hash representing the JSON payload
120   # repo::
121   #   the full name of the repository (e.g. "ruby-rbot/rbot")
122   # author::
123   #   the sender login (e.g. "Oblomov")
124   # action::
125   #   the hook action
126   # ref::
127   #   the ref referenced by the event
128   # number::
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'
130   # title::
131   #   title of the object
132   # link::
133   #   the HTML link
134   def define_filters
135     @hostkey ||= :"webhook.host"
136     @outkey ||= :"webhook.out"
137
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]
143       end
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}>"])
151     end
152
153     # the github host filter is actually implemented below
154     webhook_host_filter :github do |s|
155       github_host_filter(s)
156     end
157
158     # gitea is essentially compatible with github
159     webhook_host_filter :gitea do |s|
160       github_host_filter(s)
161     end
162
163     # gitlab has a different one
164     webhook_host_filter :gitlab do |s|
165       gitlab_host_filter(s)
166     end
167
168     @user_types ||= datafile 'filters.rb'
169     load_filters
170     load_filters :path => @user_types
171   end
172
173   # Map the event name to the payload key storing the essential information
174   GITHUB_EVENT_KEY = {
175     :issues => :issue,
176     :ping => :hook,
177   }
178
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]
184
185     return nil unless request['x-github-event']
186
187     repo = json[:repository]
188     return nil unless repo
189     repo = repo[:full_name]
190     return nil unless repo
191
192     return nil unless repo == req_repo
193
194     event = request.header['x-github-event'].first.to_sym
195
196     obj = nil
197     link = nil
198     title = nil
199
200     event_key = GITHUB_EVENT_KEY[event] || event
201
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]
205     if obj
206       link = json[:comment][:html_url] rescue nil if event == :issue_comment
207       link ||= obj[:html_url] || obj[:url]
208       title = obj[:title]
209     else
210       link = json[:html_url] || json[:url] || json[:compare]
211     end
212     title ||= json[:zen] || json[:commits].last[:message].lines.first.chomp rescue nil
213
214     stream_hash = { :event => event,
215                     :event_key => event_key,
216                     :ref => json[:ref],
217                     :author => (json[:sender][:login] rescue nil),
218                     :action => json[:action] || event,
219                     :title => title,
220                     :link => link
221     }
222
223     stream_hash[:ref] ||= json[:base][:ref] if json[:base]
224
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
229
230     debug stream_hash
231
232     return input_stream.merge stream_hash
233   end
234
235   GITLAB_EVENT_ACTION = {
236     'push' => 'pushed',
237     'tag_push' => 'pushed tag',
238     'note' => 'commented on'
239   }
240
241   def gitlab_host_filter(input_stream)
242     request = input_stream[:request]
243     json = input_stream[:payload]
244     req_repo = input_stream[:repo]
245
246     return nil unless request['x-gitlab-event']
247
248     repo = json[:project]
249     return nil unless repo
250     repo = repo[:path_with_namespace]
251     return nil unless repo
252
253     return nil unless repo == req_repo
254
255     event = json[:object_kind]
256     if not event
257       debug "No object_kind found in JSON"
258       return nil
259     end
260
261     event_key = :object_attributes
262     obj = json[event_key]
263
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
266
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
269     notable = nil
270     [:commit, :merge_request, :issue, :snippet].each do |k|
271       if json.has_key?(k)
272         notable = json[k]
273         break
274       end
275     end
276
277     link = obj ? obj[:url] : nil
278     title = notable ? notable[:title] : obj ? obj[:title] : nil
279     title ||= json[:commits].last[:title] rescue nil
280
281     # TODO https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
282
283     stream_hash = { :event => event,
284                     :event_key => event_key,
285                     :ref => json[:ref],
286                     :author => user ? user[:username] : json[:user_username],
287                     :action => GITLAB_EVENT_ACTION[event] || (obj ? (obj[:action] || 'created') :  event),
288                     :title => title,
289                     :link => link,
290                     :text => obj ? (obj[:note] || obj[:description]) : nil
291     }
292
293     stream_hash[:ref] ||= obj[:target_branch] if obj
294
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
299
300     debug stream_hash
301     return input_stream.merge stream_hash
302   end
303
304   def initialize
305     super
306     define_filters
307
308     # @repos is hash the maps each reapo to a hash of watchers
309     # channel => filter
310     @repos = {}
311     if @registry.has_key?(:repos)
312       @repos = @registry[:repos]
313     end
314   end
315
316   def name
317     "webhook"
318   end
319
320   def save
321     @registry[:repos] = Hash.new.merge @repos
322   end
323
324   def help(plugin, topic="")
325     case topic
326     when "watch"
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")
330     when "unwatch"
331       " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
332     else
333       " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
334     end
335   end
336
337   def watch_repo(m, params)
338     repo = params[:repo]
339     chan = (params[:chan] || m.replyto).downcase
340     filter = params[:filter] || :default
341
342     @repos[repo] ||= {}
343     @repos[repo][chan] = filter
344     m.okay
345   end
346
347   def unwatch_repo(m, params)
348     repo = params[:repo]
349     chan = (params[:chan] || m.replyto).downcase
350
351     if @repos.has_key?(repo)
352       @repos[repo].delete(chan)
353       m.okay
354       if @repos[repo].empty?
355         @repos.delete(repo)
356         m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
357       end
358     else
359       m.reply _("repo %{repo} not found") % params
360     end
361   end
362
363   # Display the host filters
364   def list_host_filters(m, params)
365     ar = @bot.filter_names(@hostkey)
366     if ar.empty?
367       m.reply _("No custom service filters registered")
368     else
369       m.reply ar.map { |k| k.to_s }.sort!.join(", ")
370     end
371   end
372
373   # Display the known output filters
374   def list_output_filters(m, params)
375     ar = @bot.filter_names(@outkey)
376     ar.delete(:default)
377     if ar.empty?
378       m.reply _("No custom output filters registered")
379     else
380       m.reply ar.map { |k| k.to_s }.sort!.join(", ")
381     end
382   end
383
384   # Display the known repos and watchers
385   def list_repos(m, params)
386     if @repos.empty?
387       m.reply "No repos defined"
388       return
389     end
390     msg = @repos.map do |repo, watchers|
391       [Bold + repo + Bold, watchers.map do |channel, filter|
392         "#{channel} (#{filter})"
393       end.join(", ")].join(": ")
394     end.join(", ")
395     m.reply msg
396   end
397
398   def filter_hook(json, request)
399     announce_method = @bot.config['webhook.announce_method']
400
401     debug request
402     debug json
403
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)
408
409       debug key
410       processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
411       debug processed
412       next unless processed
413
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|
417         begin
418           key = @bot.global_filter_name(filter, @outkey)
419           key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
420
421           debug key
422           output = @bot.filter(key, processed)
423           debug output
424
425           @bot.__send__(announce_method, channel, output)
426         rescue => e
427           error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
428           debug e.inspect
429           debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
430         end
431       end
432       # match found, stop checking
433       break
434     end
435   end
436
437   def process_hook(m, params)
438     json = nil
439     begin
440       json = JSON.parse(m.req.body, :symbolize_names => true)
441     rescue => e
442       error "Failed to parse request #{m.req}"
443       debug m.req
444       debug e.inspect
445       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
446     end
447
448     # Send the response early
449     if not json
450       m.send_plaintext("Failed\n", 400)
451       return
452     end
453
454     m.send_plaintext("OK\n", 200)
455
456     begin
457       filter_hook(json, m.req)
458     rescue => e
459       error e
460       debug e.inspect
461       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
462     end
463   end
464
465 end
466
467 plugin = WebHookPlugin.new
468 plugin.web_map "/webhook", :action => :process_hook
469
470 plugin.map 'webhook watch :repo :filter [in :chan]',
471   :action => :watch_repo,
472   :defaults => { :filter => nil }
473
474 plugin.map 'webhook unwatch :repo [in :chan]',
475   :action => :unwatch_repo
476
477 plugin.map 'webhook list [repos]',
478   :action => 'list_repos'
479
480 plugin.map 'webhook [list] filters',
481   :action => 'list_output_filters'
482
483 plugin.map 'webhook [list] hosts',
484   :action => 'list_host_filters'
485
486 plugin.map 'webhook [list] services',
487   :action => 'list_host_filters'