# Bugzilla plugin for rbot # Copyright (c) 2005-2008 Diego Pettenò # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . require 'set' require 'rexml/document' require 'csv' # Try loading htmlentities for entity expansion, but don't fail even # if it's not available. begin require 'htmlentities' require 'htmlentities/string' rescue LoadError # If we don't have htmlentities extension available, replace # decode_entities with a dummy function. class String def decode_entities return self end end end class BugzillaPlugin < Plugin # Valid statuses # 'DONE' and 'OPEN' are special cases that expand to the rest of the statuses in that array DONE_STATUS = ['DONE','RESOLVED','VERIFIED','CLOSED'] OPEN_STATUS = ['OPEN','UNCONFIRMED','NEW','ASSIGNED','REOPENED'] VALID_RESO = ['FIXED', 'INVALID', 'WONTFIX', 'LATER', 'REMIND', 'DUPLICATE', 'WORKSFORME', 'CANTFIX', 'NEEDINFO', 'TEST-REQUEST', 'UPSTREAM'] # Each zilla instance may have these parameters OPTIONS = [ 'name', 'baseurl', 'dataurl', 'showbugurl', 'reporturl' ] # Exception class to raise when requesting information about an # unknown zilla instance. class EMissingZilla < ::Exception def initialize(zilla) @zilla = zilla end def message "Undefined zilla #{@zilla}" end end # Base Bugzilla exception, to avoid repeating the initialize every # time in the next exceptions class Exception < ::Exception def initialize(zilla, bugno) @zilla = zilla @bugno = bugno end end # Exception class for an error loading the bug data. # It is thrown when REXML can't create a new document from the data # returned by the HTTP connection class EErrorLoading < Exception def message "Unable to load bug ##{@bugno} from #{@zilla}" end end # Exception class for an error parsing the bug data. # It is thrown when the XML document does not contain either a # or element that is recognised. class EErrorParsing < Exception def message "Unable to parse bug ##{@bugno} from #{@zilla}: no valid document element." end end # Exception class for a not found bug. # When asking for a non-existant bug, Bugzilla will return a proper # status code of 404 on the XML itself. class ENotFound < Exception def message "Bug ##{@bugno} not found in #{@zilla}" end end # Exception class for an invalid bugzilla instance data. # # When loading a bugzilla instance from the registry, if the data is # inconsistent, throw a fit by raising this exception. class EInvalidInstance < ::Exception def initialize(zilla, extramessage) @zilla = zilla @extramessage = extramessage end def message "Invalid bugzilla instance #{@zilla}: #{@extramessage}" end end # Class handling the data for a bugzilla instance. # # This class maintain all the information needed to access the # bugzilla, and takes care of getting the information out of it. class BugzillaInstance attr_reader :name def baseurl @registry["zilla.#{name}.baseurl"] end def baseurl=(val) val = val[0..-2] if val[-1].chr == '/' @registry["zilla.#{name}.baseurl"] = val delete_client end def dataurl @dataurl = @registry["zilla.#{name}.dataurl"] unless @dataurl unless @dataurl guess_dataurl end return @dataurl end def dataurl=(val) @dataurl = @registry["zilla.#{name}.dataurl"] = val end def showbugurl @showbugurl = @registry["zilla.#{name}.showbugurl"] unless @showbugurl unless @showbugurl guess_showbugurl end return @showbugurl end def showbugurl=(val) @showbugurl = @registry["zilla.#{name}.showbugurl"] = val end def reporturl @reporturl = @registry["zilla.#{name}.reporturl"] unless @reporturl unless @reporturl guess_reporturl end return @reporturl end def reporturl=(val) @reporturl = @registry["zilla.#{name}.reporturl"] = val end def lastseenid return @registry["zilla.#{name}.lastseenid"] end def lastseenid=(val) @registry["zilla.#{name}.lastseenid"] = val end def initialize(registry, bot) raise EInvalidInstance("", "Missing registry instance") unless registry raise EInvalidInstance("", "Missing bot instance") unless bot @registry = registry @bot = bot end def create(name, baseurl) raise EInvalidInstance("", "Missing instance name") unless name raise EInvalidInstance("", "Missing instance base URL") unless baseurl @name = name self.baseurl = baseurl # Do this otherwise the array is not saved properly in the registry @registry["zillas"] = (@registry["zillas"] << @name) end def delete @registry["zillas"] = (@registry["zillas"] - [@name]) OPTIONS.each do |s| @registry.delete("zilla.#{name}.#{s}") end end def load(name) raise EInvalidInstance("", "Missing instance name") unless name @name = name end # Guess at the public URL to show for a bug. def guess_showbugurl @showbugurl = baseurl @showbugurl += "/" unless baseurl[-1..-1] == "/" @showbugurl += "show_bug.cgi?id=@BUGNO@" end # Guess at the URL for the XML format of any given bug. # # We don't need to know a correct bug number for this as we can # check the answer for a 404 status code or notfound error. def guess_dataurl # First off let's see if xml.cgi is present begin test_dataurl = "#{baseurl}/xml.cgi?id=@BUGNO@" test_bugdata = REXML::Document.new(@bot.httputil.get(test_dataurl.gsub("@BUGNO@", "50"))) if test_bugdata.root.name == "bugzilla" @dataurl = test_dataurl return end rescue nil end # If not fall back to asking for the XML data to show_bug.cgi begin test_dataurl = "#{showbugurl}&ctype=xml" test_bugdata = REXML::Document.new(@bot.httputil.get(test_dataurl.gsub("@BUGNO@", "50"))) if test_bugdata.root.name == "bugzilla" @dataurl = test_dataurl return end rescue nil end @dataurl = nil end # Guess at the default URL to use for generating CSV tables format out of reports. def guess_reporturl @reporturl = "#{baseurl}/report.cgi?action=wrap&ctype=csv&format=table" end # Deletes the client object if any def delete_client # TODO: httpclient does not seem to provide a way to close the # connection as of now, until that is implemented this is just a # dummy function, and the plugin will leak connections on # rescan. @client = nil end # Return the summary for a given bug. def summary(bugno) raise EInvalidInstance.new(self.name, "No XML data URL available") if dataurl == nil bugdata = REXML::Document.new(@bot.httputil.get(dataurl.gsub("@BUGNO@", bugno))) raise EErrorLoading.new(name, bugno) unless bugdata # OpenOffice's issuezilla is tricky, they call it issue_status, so # we have to consider the alternative in case there is an # as document element. bugxml = bugdata.root.get_elements("bug")[0] bugxml = bugdata.root.get_elements("issue")[0] unless bugxml raise EErrorParsing.new(name, bugno) unless bugxml if bugxml.attribute("status_code").to_s == "404" or bugxml.attribute("error").to_s.downcase == "notfound" raise ENotFound.new(name, bugno) end product_component = "#{bugxml.get_text("product")} | #{bugxml.get_text("component")}". chomp(" | ") status = "#{bugxml.get_text("bug_status")}#{bugxml.get_text("issue_status")}, #{bugxml.get_text("resolution")}". chomp(", ") desc = bugxml.get_text("short_desc").to_s.decode_entities return "" + "Bug #{bugno}; " + "\"#{desc}\"; " + "#{product_component}; " + "#{status}; " + "#{bugxml.get_text("reporter")} -> #{bugxml.get_text("assigned_to")}; " + "#{showbugurl.gsub('@BUGNO@', bugno)}" end def add_announcement(channel_name) @registry["zilla.#{@name}.announcements"] = Set.new unless @registry["zilla.#{@name}.announcements"] @registry["zilla.#{@name}.announcements"] = @registry["zilla.#{@name}.announcements"] + [channel_name] end def delete_announcement(channel_name) return unless @registry["zilla.#{@name}.announcements"] @registry["zilla.#{@name}.announcements"] = @registry["zilla.#{@name}.announcements"] - [channel_name] end def announce return unless @registry["zilla.#{@name}.announcements"] buglist_url = baseurl + "/buglist.cgi?ctype=csv&order=bugs.bug_id" if lastseenid == nil buglist_url += "&chfieldfrom=-6h&chfieldto=Now&chfield=%5BBug+creation%5D" else buglist_url += "&field0-0-0=bug_id&remaction=&type0-0-0=greaterthan&value0-0-0=#{lastseenid}" end buglist = CSV::Reader.create(@bot.httputil.get(buglist_url)).to_a buglist.delete_at(0) upper_bound = [buglist.size, 5].min buglist[-upper_bound..-1].each do |bug| bugsummary = summary(bug[0]) @registry["zilla.#{@name}.announcements"].each do |chan| @bot.say chan, "New bug: #{bugsummary}" end end self.lastseenid = buglist[-1][0].to_i if buglist.size > 0 end def report(params) url = "#{reporturl}&#{params}" reportdata = CSV::Reader.create(@bot.httputil.get(url)).to_a return reportdata end end # Initialise the bugzilla plugin. # def initialize super @zillas = {} if @registry["zillas"] @registry["zillas"].each do |zilla| instance = BugzillaInstance.new(@registry, @bot) instance.load(zilla) @zillas[zilla] = instance end else @registry["zillas"] = Array.new end @defaults = Hash.new if @registry["channel_defaults"] channel_defaults_reload else @registry["channel_defaults"] = Hash.new end @polling_timer = @bot.timer.add(300) { poll_zillas } end # Cleanup the plugin on reload # # This function is used to remove timers and close HTTPClient # instances, otherwise they'll be kept open with no good reason. def cleanup @bot.timer.remove(@polling_timer) super end # Check for the existence of zilla in the registry. # This function checks if a given zilla is present in the registry # file by checking for presence of a zilla. entry. It raises # an exception if it is missing. def check_zilla(name) raise EMissingZilla.new(name) unless @zillas.has_key?(name) end # Function "eavesdropping" on all the messages the bot receives. # # This function is used to check if an user requested bug # information inline in the text of a message rather than directly # to the bot. def listen(m) return if m.address? return if m.message !~ /\bbug\b.*\b#?([0-9]+)/ bugno = $1 return unless @defaults[m.replyto][:eavesdrop] return unless @defaults[m.replyto][:zilla] return unless @zillas[@defaults[m.replyto][:zilla]] m.reply @zillas[@defaults[m.replyto][:zilla]].summary(bugno) end # Function checking when a new channel is joined # # This function will calculate the channel default. def join(m) return unless m.address? @registry["channel_defaults"].each do |chanrexp, defaults| if m.replyto.to_s =~ Regexp.new(chanrexp) @defaults[m.replyto] = { :eavesdrop => defaults[:eavesdrop], :zilla => @zillas[defaults[:zilla]] } break end end end # Answer to a bug information request # # This is the main function of the plugin, answering to bug # information requests from users. If the user provides a named # zilla, use that, otherwise see if the channel the user asked in # has a default. def bug(m, params) begin bugno = params[:number].chomp("#") if params[:zilla] and bugno check_zilla(params[:zilla]) m.reply @zillas[params[:zilla]].summary(bugno) elsif @defaults.key?(m.replyto) and @defaults[m.replyto][:zilla] and @zillas[@defaults[m.replyto][:zilla]] m.reply @zillas[@defaults[m.replyto][:zilla]].summary(bugno) else m.reply "Wrong parameters, see 'help bug' for help." end rescue ::Exception => e m.reply e.message end end # Produce architecture statistics using Bugzilla reports # # Using the bugzilla reporting functionality, we can produce a # simple report of bugs by architecture, for any specific # status/resolution. # x_axis_field=rep_platform # x_axis_field=rep_platform&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED # x_axis_field=rep_platform&resolution=FIXED&resolution=INVALID&resolution=WONTFIX def archstats(m, params) begin status = params[:status] reso = params[:reso] # rbot gets the assignment order wrong sometimes if reso and status.nil? status = reso reso = nil end # Now the real defaults status = 'ALL' unless status reso = '' unless reso # Validate all input status = status.split(/,/) status.each do |s| reso = [] unless DONE_STATUS.include?(s) raise ENotFound.new("Invalid status (#{s}), see 'help archstats' for help.") if not DONE_STATUS.include?(s) and not OPEN_STATUS.include?(s) and s != 'ALL' end reso = reso.split(/,/) if reso and reso.is_a?(String) reso.each do |r| raise ENotFound.new("Invalid resolution (#{r}), see 'help archstats' for help.") if not VALID_RESO.include?(r) end # Nice header title = "Platform bug totals" if status.length > 0 or reso.length > 0 title += " (#{status.join(',')}" title += "/#{reso.join(',')}" if reso.length > 0 title += ")" end # Special cases if status.include?('ALL') status << 'OPEN' status << 'DONE' status.delete('ALL') end if status.include?('OPEN') status += OPEN_STATUS status.uniq! status.delete('OPEN') end if status.include?('DONE') status += DONE_STATUS status.uniq! status.delete('DONE') end # Build our URL query = 'x_axis_field=rep_platform' status.each { |s| query += "&bug_status=#{s}" } reso.each { |r| query += "&resolution=#{r}" } # Get the data if params[:zilla] check_zilla(params[:zilla]) results = @zillas[params[:zilla]].report(query) elsif @defaults[m.replyto][:zilla] and @zillas[@defaults[m.replyto][:zilla]] results = @zillas[@defaults[m.replyto][:zilla]].report(query) else m.reply "Wrong parameters, see 'help archstats' for help." return end # Remove the CSV header results.shift # Display output m.reply title+" "+(results.map { |b| "#{b[0]}(#{b[1]})" }.join(' ')) rescue ::Exception => e m.reply e.message end end # Adds a new instance to the available instances # # This function creates a new BugzillaInstance object, loads the new # data on it, and then adds it to the hash of zillas. # # Only the base url of the instance is needed, the rest of the # parameters will either default or get guessed by the bot. # # To override the settings, use the set zilla command def instance_add(m, params) if @zillas.has_key?("#{params[:zilla]}") m.reply "Bugzilla #{params[:zilla]} already present." return end instance = BugzillaInstance.new(@registry, @bot) instance.create(params[:zilla], params[:baseurl]) @zillas[params[:zilla]] = instance m.reply "Added #{params[:zilla]}" end # Set parameters for the given bugzilla # # There is a special bit of behavior here. If you want to UNSET an option, so # that the default is used, then set it to 'nil'. def instance_set(m, params) begin # This is to save us from having an 'unset' command params[:value] = nil if params[:value].match(/^nil$/) # We are evil @zillas[params[:zilla]].send("#{params[:setting]}=", params[:value]) rescue ::Exception => e m.reply e.message end end # Removes an instance to the available instances. # # The opposite of instance_add, this function deletes an instance of # Bugzilla or Issuezilla from the registry. def instance_delete(m, params) @zillas[params[:zilla]].delete @zillas.delete(params[:zilla]) m.okay end # Shows the list of available instances to the users. def instance_list(m, params) m.reply @registry["zillas"].join(", ") end # Show the information known about the bugzilla. # # This function emits a summary of the data regarding the bugzilla, # its output can be used to set the bugzilla back up again on this # or other instances. def instance_show(m, params) begin check_zilla(params[:zilla]) msg = "#{params[:zilla]}" for s in OPTIONS if params[:full] == 'full' o = @zillas[params[:zilla]].send(s) elsif params[:full] == 'registry' o = @registry['zilla.' + params[:zilla] + ".#{s}"] end msg += " #{s}: #{o}" if o end m.reply msg rescue ::Exception => e m.reply e.message end end # Reloads the defaults for the current joined channels # # This function scans through the list of channel defaults found in # the registry and report them in the locally accessed objects. def channel_defaults_reload(m=nil) begin @registry["channel_defaults"].each do |chanrexp, defaults| if chanrexp =~ /^\/.*\/$/ chanrexp = Regexp.new(chanrexp[1..-2]) @bot.server.channels.each do |chan| _channel_defaults_reload_set(chan, defaults) if chan.to_s =~ chanrexp end else _channel_defaults_reload_set(chanrexp, defaults) end end rescue ::Exception => e if m m.reply e.message else debug(e.message + "\n" + e.backtrace.join("\n\t")) end end end # Helper function only def _channel_defaults_reload_set(chan, defaults) @defaults[chan] = { :eavesdrop => defaults[:eavesdrop], :zilla => defaults[:zilla] } end # Sets the default zilla for the given channel regexp # # The default zilla is the zilla used when an user requests info # about a bug number, without saying which zilla to take the data # from. def channel_defaults_set(m, params) begin @registry["channel_defaults"] = @registry["channel_defaults"].merge(params[:channel] => { :zilla => params[:zilla], :eavesdrop => params[:eavesdrop] == "on" }) channel_defaults_reload m.okay rescue ::Exception => e m.reply e.message end end # Unsets the default zilla for the given channel regexp def channel_defaults_unset(m, params) begin @registry["channel_defaults"].delete(params[:channel]) channel_defaults_reload m.okay rescue ::Exception => e m.reply e.message end end # Display the list of channels/users for which we have defaults def channel_defaults_list(m, params) begin m.reply @registry["channel_defaults"].keys.join(', ') rescue ::Exception => e m.reply e.message end end # Show the default for a given channel/user def channel_defaults_show(m, params) begin defl = @registry["channel_defaults"][params[:channel]] m.reply "#{params[:channel]}: #{defl.inspect}" rescue ::Exception => e m.reply e.message end end def channel_defaults_dump(m, params) begin m.reply @defaults.inspect rescue ::Exception => e m.reply e.message end end # Adds announcement for bugs on the given zilla to the channel # # When this function is called, the given zilla is added to the list # of zilla to announce in the given channel. # # Zillas being announced mean they get polled at a fixed interval # for new bugs, and the summary for those is sent to the channel # asking for them. # # Actually, it's the channel being added to the announcement for # the given zilla, as that makes it quite easier to track down which # ones to poll. def channel_announcement_add(m, params) begin @zillas[params[:zilla]].add_announcement params[:channel] m.okay rescue ::Exception => e m.reply e.message end end # Removes an announcement of a given zilla on a channel. # # This basically is an undo function for the function above. def channel_announcement_delete(m, params) begin @zillas[params[:zilla]].delete_announcement(params[:channel]) m.okay rescue ::Exception => e m.reply e.message end end def poll_zillas @zillas.each do |name, zilla| begin zilla.announce rescue Exception => e debug(e.message + "\n" + e.backtrace.join("\n\t")) end end end # Help strings to give the users when they are asking for it. @@help_zilla = { "bug" => "bug #{Bold}[bugzilla]#{Bold} #{Bold}number#{Bold} : show the data about given bugzilla's bug.", "archstats" => "archstats #{Bold}[bugzilla]#{Bold} #{Bold}[status]#{Bold} #{Bold}[reso]#{Bold} : show architecture summaries for given bug statuses.", "zilla" => "zilla #{Bold}instance#{Bold}|#{Bold}default#{Bold}|#{Bold}source#{Bold}|#{Bold}credits#{Bold} : manages bugzilla lists.", "zilla instance" => "zilla instance #{Bold}add#{Bold}|#{Bold}delete#{Bold}|#{Bold}set#{Bold}|#{Bold}show#{Bold}|#{Bold}list#{Bold} : handle bugzilla instances", "zilla instance add" => "zilla instance add #{Bold}name#{Bold} #{Bold}baseurl#{Bold} : adds a new bugzilla (use \#{bugno} in URLs to replace the bug number)", "zilla instance delete" => "zilla instance delete #{Bold}name#{Bold} : delete the named bugzilla", "zilla instance set" => "zilla instance set #{Bold}name#{Bold} #{Bold}option#{Bold} #{Bold}value#{Bold} : set the option to a given value for the zilla. Valid options are " + OPTIONS.join(", "), "zilla instance list" => "zilla instance list : shows current querable bugzilla instancess", "zilla instance show" => "zilla instance show #{Bold}name#{Bold} : shows the configuration for the named bugzilla.", "zilla default" => "zilla default #{Bold}set#{Bold}|#{Bold}unset#{Bold}|#{Bold}list#{Bold}|#{Bold}show#{Bold} : handles default zilla for channels", "zilla default set" => "zilla default set #{Bold}channel_name#{Bold} #{Bold}zilla_name#{Bold} #{Bold}eavesdrop_on|off#{Bold} : sets the default zilla for a given channel, use on or off to enable or disable eavesdropping for bug references.", "zilla default unset" => "zilla default unset #{Bold}channel_name#{Bold} : unsets the default zilla for a given channel", "zilla default list" => "zilla default list : shows all channels for which a default is set", "zilla default show" => "zilla default show #{Bold}channel_name#{Bold} : show the default for a given channel", "zilla source" => "zilla source : shows a link to the plugin's sources.", "zilla credits" => "zilla credits : shows the plugin's credits and license." } def help(plugin, topic = "") cmd = plugin cmd += " "+topic if topic.length > 0 if @@help_zilla.has_key?(cmd) return @@help_zilla[cmd] else return "no help available for #{cmd}" end end def plugin_sources(m, params) m.reply "http://www.flameeyes.eu/projects#rbot-bugzilla" end def plugin_credits(m, params) m.reply "Copyright (C) 2005-2008 Diego Pettenò & Robin H. Johnson. Distributed under Affero General Public License version 3." end end plugin = BugzillaPlugin.new plugin.default_auth( 'modify', false ) plugin.default_auth( 'view', true ) plugin.map 'bug :zilla :number', :requirements => { :number => /^#?\d+$/, :zilla => /^[^ ]+$/ }, :defaults => { :zilla => nil }, :action => 'bug', :auth_path => 'view' plugin.map 'archstats :zilla :status :reso', :action => 'archstats', :requirements => { :status => /^[\w,]+$/, :resolution => /^[\w,]+$/, :zilla => /^[^ ]+$/ }, :defaults => { :status => nil, :resolution => nil, }, :auth_path => 'view' plugin.map 'zilla instance add :zilla :baseurl', :action => 'instance_add', :requirements => { :zilla => /^[^ ]+$/, :baseurl => /^https?:\/\/.*/ }, :auth_path => 'modify' plugin.map 'zilla instance set :zilla :setting :value', :action => 'instance_set', :requirements => { :zilla => /^[^\. ]+$/, :setting => /^(baseurl|dataurl|showbugurl|reporturl)$/ }, :auth_path => 'modify' plugin.map 'zilla instance delete :zilla', :action => 'instance_delete', :requirements => { :zilla => /^[^ ]+$/ }, :auth_path => 'modify' plugin.map 'zilla instance list', :action => 'instance_list', :auth_path => 'view' plugin.map 'zilla instance show :zilla :full', :action => 'instance_show', :requirements => { :zilla => /^[^ ]+$/, :full => /^full|registry$/ }, :defaults => { :full => "registry" }, :auth_path => 'view' plugin.map 'zilla default set :channel :zilla :eavesdrop', :action => 'channel_defaults_set', :requirements => { #:channel => /^[^\/][^ ]+[^\/]$|^\/#[^ ]+\/$/, :channel => /^[^ ]+$/, :zilla => /^[^ ]+$/, :eavesdrop => /^(?:on|off)$/, }, :defaults => { :eavesdrop => "off" }, :auth_path => 'modify' plugin.map 'zilla default unset :channel', :action => 'channel_defaults_unset', :requirements => { #:channel => /^[^\/][^ ]+[^\/]$|^\/#[^ ]+\/$/, }, :auth_path => 'modify' plugin.map 'zilla default list', :action => 'channel_defaults_list', :auth_path => 'view' plugin.map 'zilla default show :channel', :action => 'channel_defaults_show', :requirements => { :channel => /^[^\/][^ ]+[^\/]$|^\/#[^ ]+\/$/, }, :auth_path => 'view' plugin.map 'zilla default dump', :action => 'channel_defaults_dump', :auth_path => 'view' plugin.map 'zilla announcement add :zilla :channel', :action => 'channel_announcement_add', :requirements => { :channel => /^#[^ ]+$/, :zilla => /^[^ ]+$/ }, :auth_path => 'modify' plugin.map 'zilla announcement remove :zilla :channel', :action => 'channel_announcement_delete', :requirements => { :channel => /^#[^ ]+$/, :zilla => /^[^ ]+$/ }, :auth_path => 'modify' plugin.map 'zilla source', :action => 'plugin_sources', :auth_path => 'view' plugin.map 'zilla credits', :action => 'plugin_credits', :auth_path => 'view'