]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/webhook.rb
webhook: define number for watch/star actions too
[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     case event
231     when :watch
232       stream_hash[:number] ||= 'watching ðŸ‘€%{watchers_count}' % json[:repository]
233     when :star
234       stream_hash[:number] ||= 'star â˜† %{watchers_count}' % json[:repository]
235     end
236
237     debug stream_hash
238
239     return input_stream.merge stream_hash
240   end
241
242   GITLAB_EVENT_ACTION = {
243     'push' => 'pushed',
244     'tag_push' => 'pushed tag',
245     'note' => 'commented on'
246   }
247
248   def gitlab_host_filter(input_stream)
249     request = input_stream[:request]
250     json = input_stream[:payload]
251     req_repo = input_stream[:repo]
252
253     return nil unless request['x-gitlab-event']
254
255     repo = json[:project]
256     return nil unless repo
257     repo = repo[:path_with_namespace]
258     return nil unless repo
259
260     return nil unless repo == req_repo
261
262     event = json[:object_kind]
263     if not event
264       debug "No object_kind found in JSON"
265       return nil
266     end
267
268     event_key = :object_attributes
269     obj = json[event_key]
270
271     user = json[:user] # may be nil: some events use keys such as user_username
272     # TODO we might want to unify this at the rbot level
273
274     # comments have a noteable_type, but this is not the key of the object used
275     # so instead we just look for the known keys
276     notable = nil
277     [:commit, :merge_request, :issue, :snippet].each do |k|
278       if json.has_key?(k)
279         notable = json[k]
280         break
281       end
282     end
283
284     link = obj ? obj[:url] : nil
285     title = notable ? notable[:title] : obj ? obj[:title] : nil
286     title ||= json[:commits].last[:title] rescue nil
287
288     # TODO https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
289
290     stream_hash = { :event => event,
291                     :event_key => event_key,
292                     :ref => json[:ref],
293                     :author => user ? user[:username] : json[:user_username],
294                     :action => GITLAB_EVENT_ACTION[event] || (obj ? (obj[:action] || 'created') :  event),
295                     :title => title,
296                     :link => link,
297                     :text => obj ? (obj[:note] || obj[:description]) : nil
298     }
299
300     stream_hash[:ref] ||= obj[:target_branch] if obj
301
302     num = notable ? (notable[:iid] || notable[:id]) : obj ? obj[:iid] || obj[:id] : nil
303     stream_hash[:number] = '%{object} #%{num}' % { :num => num, :object => (obj[:noteable_type] || event).to_s.gsub('_', ' ') } if num
304     num = json[:total_commits_count]
305     stream_hash[:number] = _("%{num} commits") % { :num => num } if num
306
307     debug stream_hash
308     return input_stream.merge stream_hash
309   end
310
311   def initialize
312     super
313     define_filters
314
315     # @repos is hash the maps each reapo to a hash of watchers
316     # channel => filter
317     @repos = {}
318     if @registry.has_key?(:repos)
319       @repos = @registry[:repos]
320     end
321   end
322
323   def name
324     "webhook"
325   end
326
327   def save
328     @registry[:repos] = Hash.new.merge @repos
329   end
330
331   def help(plugin, topic="")
332     case topic
333     when "watch"
334       ["webhook watch #{Bold}repository#{Bold} #{Bold}filter#{Bold} [in #{Bold}\#channel#{Bold}]: announce webhook triggers matching the given repository, using the given output filter.",
335        "the repository should be defined as service:name where service is known service, and name the actual repository name.",
336        "example: webhook watch github:ruby-rbot/rbot github"].join("\n")
337     when "unwatch"
338       " unwatch #{Bold}repository#{Bold} [in #{Bold}\#channel#{Bold}]: stop announcing webhhoks from the given repository"
339     else
340       " [un]watch <repository> [in #channel]: manage webhhok announcements for the given repository in the given channel"
341     end
342   end
343
344   def watch_repo(m, params)
345     repo = params[:repo]
346     chan = (params[:chan] || m.replyto).downcase
347     filter = params[:filter] || :default
348
349     @repos[repo] ||= {}
350     @repos[repo][chan] = filter
351     m.okay
352   end
353
354   def unwatch_repo(m, params)
355     repo = params[:repo]
356     chan = (params[:chan] || m.replyto).downcase
357
358     if @repos.has_key?(repo)
359       @repos[repo].delete(chan)
360       m.okay
361       if @repos[repo].empty?
362         @repos.delete(repo)
363         m.reply _("No more watchers, I'll forget about %{repo} altogether") % params
364       end
365     else
366       m.reply _("repo %{repo} not found") % params
367     end
368   end
369
370   # Display the host filters
371   def list_host_filters(m, params)
372     ar = @bot.filter_names(@hostkey)
373     if ar.empty?
374       m.reply _("No custom service filters registered")
375     else
376       m.reply ar.map { |k| k.to_s }.sort!.join(", ")
377     end
378   end
379
380   # Display the known output filters
381   def list_output_filters(m, params)
382     ar = @bot.filter_names(@outkey)
383     ar.delete(:default)
384     if ar.empty?
385       m.reply _("No custom output filters registered")
386     else
387       m.reply ar.map { |k| k.to_s }.sort!.join(", ")
388     end
389   end
390
391   # Display the known repos and watchers
392   def list_repos(m, params)
393     if @repos.empty?
394       m.reply "No repos defined"
395       return
396     end
397     msg = @repos.map do |repo, watchers|
398       [Bold + repo + Bold, watchers.map do |channel, filter|
399         "#{channel} (#{filter})"
400       end.join(", ")].join(": ")
401     end.join(", ")
402     m.reply msg
403   end
404
405   def filter_hook(json, request)
406     announce_method = @bot.config['webhook.announce_method']
407
408     debug request
409     debug json
410
411     @repos.each do |s_repo, watchers|
412       host, repo = s_repo.split(':', 2)
413       key = @bot.global_filter_name(host, @hostkey)
414       error "No host filter for #{host} (from #{s_repo})" unless @bot.has_filter?(key)
415
416       debug key
417       processed = @bot.filter(key, { :payload => json, :request => request, :repo => repo })
418       debug processed
419       next unless processed
420
421       # TODO if we see that the same output filter is applied to multiple channels,
422       # we should group the channels by filters and only do the output processing once
423       watchers.each do |channel, filter|
424         begin
425           key = @bot.global_filter_name(filter, @outkey)
426           key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
427
428           debug key
429           output = @bot.filter(key, processed)
430           debug output
431
432           @bot.__send__(announce_method, channel, output)
433         rescue => e
434           error "Failed to announce #{json} for #{repo} in #{channel} with filter #{filter}"
435           debug e.inspect
436           debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
437         end
438       end
439       # match found, stop checking
440       break
441     end
442   end
443
444   def process_hook(m, params)
445     json = nil
446     begin
447       json = JSON.parse(m.req.body, :symbolize_names => true)
448     rescue => e
449       error "Failed to parse request #{m.req}"
450       debug m.req
451       debug e.inspect
452       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
453     end
454
455     # Send the response early
456     if not json
457       m.send_plaintext("Failed\n", 400)
458       return
459     end
460
461     m.send_plaintext("OK\n", 200)
462
463     begin
464       filter_hook(json, m.req)
465     rescue => e
466       error e
467       debug e.inspect
468       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
469     end
470   end
471
472 end
473
474 plugin = WebHookPlugin.new
475 plugin.web_map "/webhook", :action => :process_hook
476
477 plugin.map 'webhook watch :repo :filter [in :chan]',
478   :action => :watch_repo,
479   :defaults => { :filter => nil }
480
481 plugin.map 'webhook unwatch :repo [in :chan]',
482   :action => :unwatch_repo
483
484 plugin.map 'webhook list [repos]',
485   :action => 'list_repos'
486
487 plugin.map 'webhook [list] filters',
488   :action => 'list_output_filters'
489
490 plugin.map 'webhook [list] hosts',
491   :action => 'list_host_filters'
492
493 plugin.map 'webhook [list] services',
494   :action => 'list_host_filters'