#-- #------------------------------------------------------------------------------- #++ # #= Introduction # #== Name: P4Triggers.rb # #== Author: Tony Smith # Robert Cowham # #== Description # # A library for use when writing Perforce triggers. Methods and classes # common to all my example triggers are to be found in here. # # The framework for pre-submit/content/commit triggers is very simple: # # 1. You derive your trigger class from P4Trigger # 2. You implement the validate() method to decide whether or not to # allow the submit # 3. You send output to the user using the message() method # 4. You raise exceptions to report runtime errors P4Exceptions are # already handled for you. # 5. Your trigger script should call P4Trigger#parse_change() and should use # the value returned as its exit status. # # And that's all there is to it! # # For other trigger types (spec triggers) there's no generic framework, # yet. But there is a class (P4Triggers::FormFile) to help you work with # the temporary form files the server creates. # # The P4Change class merits some familiarisation because objects of this # class are what you get when you call P4Trigger#change() and it's a # very convenient way to examine the changelist being submitted. # # If you're just getting started with this framework, start by looking at # the P4Trigger class and then look at P4Change. # # == Version # # $Id: //guest/robert_cowham/perforce/utils/triggers/P4Triggers.rb#4 $ # #-- #------------------------------------------------------------------------------- #++ require "P4" require "optparse" require "ostruct" require "pp" # # A class for holding info about a change. This is intended to be populated # be the output of "p4 describe" rather than "p4 change -o". The difference # is mostly in the case of the hash keys, but that's significant enough. # class P4Change # Constructor. Pass the hash returned by P4#run_describe( "-s" ) in # tagged mode. def initialize( hash ) @change = hash[ "change" ] @user = hash[ "user" ] @client = hash[ "client" ] @desc = hash[ "desc" ] @time = Time.at( hash[ "time" ].to_i ) @status = hash[ "status" ] @files = Array.new @jobs = Hash.new if ( hash.has_key?( "depotFile" ) ) hash[ "depotFile" ].each_index do |i| name = hash[ "depotFile" ][ i ] type = hash[ "type" ][ i ] rev = hash[ "rev" ][ i ] act = hash[ "action" ][ i ] df = P4DepotFile.new( name ) dr = df.new_revision dr.type = type dr.revno = rev.to_i dr.action = act @files.push( df ) end end if ( hash.has_key?( "job" ) ) hash[ "job" ].each_index do |i| job = hash[ "job" ][ i ] status = hash[ "jobstat" ][ i ] @jobs[ job ] = status end end end attr_reader :change, :user, :client, :desc, :time, :status, :files, :jobs # Shorthand iterator for looking at the files in the change def each_file( &block ) @files.each { |f| yield( f ) } end # Shorthand iterator for looking at the jobs fixed by the change def each_job( &block ) @jobs.each { |j| yield( j ) } end end # # A class for encapsulating file types - type checking is quite common in # many triggers so it's nice to have it available to all. # class FileType # Provide a regexp that the filetype must match and a message for the # types that match the regexp. i.e. # # FileType.new( /u?binary/, "binary/ubinary" ) # def initialize( regexp, message ) @re = regexp @msg = message end attr_reader :msg # Test to see if the given filetype matches this type using the # regexp. def match( type ) @re.match( type ) end end # # Reopen the P4 class to restructuring the output of "p4 describe -s" into a # P4Change object, and to disable any attempt to submit - just in case # someone's foolish enough to try # class P4 def run_describe( *args ) h = run( "describe", args ).shift return h unless h.kind_of?( Hash ) return P4Change.new( h ) end # Disable the submit_spec interface def submit_spec raise( "Attempt to submit during trigger execution. Don't do it." ) end # Disable the direct interface to submit def run_submit raise( "Attempt to submit during trigger execution. Don't do it." ) end end # # == Description # # A class providing a generic framework for all triggers. It provides # rudimentary error handling so all errors get logged to stderr, and we # also have a method for reporting a more friendly message to the user. # It is intended that all trigger scripts will derive their own subclass # of P4Trigger and override/expand it as necessary. # # Since this framework serves ALL types of Perforce trigger, not all # the methods are appropriate to any given trigger type. # # == Pre-Submit Triggers # # You must override the validate() method if you want your trigger to # work. The default implementation accepts *everything* so make sure you # provide a method to reject bad changes or you will have accomplished # nothing. # # The conventional use of this module for this trigger-type is: # # exit( trig.parse_change( change_no ) ) # # The parse_change() method will call validate() and will return a # suitable exit status to pass back to the Perforce Server based on the # results of validate() # # == Content Triggers # # As for Pre-Submit Triggers # # == Commit Triggers # # As for Pre-Submit Triggers # # == Spec Triggers # # # # class P4Trigger # Constructor. # The optional +options+ parameter is an instance of P4TriggerOptions. def initialize(options = OpenStruct.new, api_level = nil) begin @options = options @change = nil @p4 = P4.new @p4.api = api_level if api_level @p4.user = @options.user || @p4.user? @p4.port = @options.port || @p4.port? @p4.client = @options.client || @p4.client? if @options.verbose message "P4PORT = #{@p4.port?}\n" message "P4USER = #{@p4.user?}\n" message "P4CLIENT = #{@p4.client?}\n" end @p4.parse_forms @password = nil if @options.passwd env_file = File.dirname(__FILE__) + "/" + @options.passwd if File.exists?(env_file) File.read(env_file).scan(/^(\w+)=([^\n]*)/).each {|k, v| case k.downcase when "p4passwd" @password = v end } end end rescue return report_error() end end # Direct access to the P4 object. You can use this for interrogating # the Perforce server. It's in parse_forms() mode and has an # exception_level of 1 so exceptions will only be raised on errors, not # warnings. attr_reader :p4 # Direct access to the changelist in question. Returns a P4Change object attr_reader :change # The main execution phase of the trigger. We assume since this is a # pre-submit trigger that there will be a changelist involved. The # steps for most triggers are common: # # 1. Find out about the changelist # 2. Enforce the rules # # We try to generalise this process so this class calls get_change() # for step 1, and validate() for step 2. Subclasses may override these # methods to tailor the trigger's behaviour # # parse_change() returns the correct exit status for the trigger so you # would normally use it like this: # # exit( trig.parse_change( change_no ) ) # def parse_change( change_no ) begin @p4.connect message "Connected.\n" if @options.verbose if @password @p4.password = @password @p4.run_login() end if ( ! change_no ) raise( "No changelist number supplied to trigger script.\n" + "Please check your trigger configuration.") end get_change( change_no ) exit_code = ( validate() ? 0 : 1 ) if @options.fail && exit_code == 0 exit_code = 1 message( "\nTrigger worked - but submit being failed due to fail flag used for testing (-f)\n" + "Ask your administrator to remove from trigger definition (p4 triggers)!\n" ) end return exit_code rescue return report_error() end end # The main execution phase of the trigger. We assume since this is a # form-in trigger that there will be formname and formfile. The # steps for most triggers are common: # # 1. Find out about the form # 2. Enforce the rules # # We try to generalise this process so this class calls get_form() # for step 1, and validate_form() for step 2. Subclasses may override these # methods to tailor the trigger's behaviour # # parse_form() returns the correct exit status for the trigger so you # would normally use it like this: # # exit(trig.parse_form(formname, formfile)) # def parse_form(formname, formfile) begin if @options.verbose message "Formname: '#{formname}'\n" message "Formfile: '#{formfile}'\n" end @p4.connect message "Connected.\n" if @options.verbose if @password @p4.password = @password @p4.run_login() end if ( !formname || !formfile ) raise( "No formname or formfile supplied to trigger script.\n" + "Please check your trigger configuration.") end @form = FormFile.new(formfile) @form_contents = @form.load_hash @formname = formname exit_code = ( validate_form() ? 0 : 1 ) return exit_code rescue return report_error() end end # # Method to encapsulate error reporting so that all types of trigger # can report error messages in a consistent way. def report_error # Full error report to stderr, so they go into the Perforce server's # logfile $stderr.puts( "\nError during trigger execution:\n\n" ) $stderr.puts( $!.to_s ) @p4.warnings.each { |w| $stderr.puts( "P4 WARNING: " + w ) } @p4.errors.each { |e| $stderr.puts( "P4 ERROR: " + e ) } $stderr.print( "\nStack Trace:\n\n\t" ) $stderr.print( $!.backtrace.join( "\n\t" ) ) $stderr.puts( "" ) $stderr.puts( "P4PORT = #{@p4.port?}\n" ) $stderr.puts( "P4USER = #{@p4.user?}\n" ) $stderr.puts( "P4CLIENT = #{@p4.client?}\n" ) # # Simpler error report (sans-stack backtrace) to stdout. # $stdout.puts( error_message() ) $stdout.puts( $!.to_s ) @p4.warnings.each { |w| $stdout.puts( "P4 WARNING: " + w ) } @p4.errors.each { |e| $stdout.puts( "P4 ERROR: " + e ) } # Get user to report password problems passwd_err = false @p4.errors.each {|e| if e == "Perforce password (P4PASSWD) invalid or unset." || e == "Your session has expired, please login again." then passwd_err = true end } if passwd_err then $stdout.puts( "\nPlease report this problem to your Perforce Administrator and\nask them to check login status for user '#{@p4.user?}'\n" ) end # Now we return false to the caller so they can return with # the correct exit status return 1 end # The default implementation of get_change. Returns a hash describing # the change based on an execution of "p4 describe -s". The hash is # also saved in the @change member which subclasses may access in their # validate() method. For most triggers this will be sufficient. def get_change( change_no ) @change = p4.run_describe( "-s", change_no ) end # The default implementation of validate(). Very simple, it accepts # everything. You are expected to override this method with one of # your own. When you do so, you can use the @change member to get # at the details of the change you're validating. def validate() true end # The default implementation of validate_form(). Very simple, it accepts # everything. You are expected to override this method with one of # your own. def validate_form() true end # # Method to send a message to the user. Just writes to stdout, but it's # nice to encapsulate that here. # def message( string ) $stdout.print( string ) log( string ) end # # Error logging (if enabled) - will go to server log file # def log( string ) $stderr.print( string ) if @options.verbose end # Default message for when things go wrong def error_message() "\n" + "An error was encountered during trigger execution. Please\n" + "contact your Perforce administrator and ask them to\n" + "investigate the cause of this error\n\n" end # # A class for encapsulating the interaction with the temporary formfile # provided by the Perforce Server for use by spec triggers. Allows you # to load/save the file contents in a manner than preserves the comment # block at the top. # class FormFile def initialize( filename ) @filename = filename @comments = "" end # # Return the contents of the formfile. Saves the comments in the # file for use in a future call to save(). # def load() f = File.open( @filename, "r" ) @comments = "" buf = "" f.each_line do |line| if( line[0...1] == '#' ) @comments += line else buf += line end end @comments += "\n" buf += "\n" end # # Return the contents of the formfile as a hash. # def load_hash() contents = load hash = Hash.new last_key = "" @key_list = Array.new # Remember key order for neatness contents.split("\n").each{|line| if line =~ /^(\w*):\s+(.*)/ hash[$1] = $2 last_key = $1 @key_list << last_key elsif line =~ /^(\w*):\s*/ last_key = $1 @key_list << last_key elsif line =~ /^\s+([^\n]*)/ (hash[last_key] ||= []) << $1 end } hash end # # Save the hash into the formfile # def save_hash(hash) contents = "" @key_list.each{|k| v = hash[k] if v.class == Array contents += "#{k}:\n\t" + v.join("\n\t") + "\n\n" else contents += "#{k}:\t#{v}\n\n" end } save(contents) end # # Update the formfile with a new definition of the spec. The input # should be a string, or respond to to_s(), and should not include # any of the leading comments. # def save( spec ) File.open( @filename, "w" ) do |f| f.write( @comments ) f.write( spec ) end end end end # # == Description # # Parses ARGV into options which are acted upon by P4Triggers. Several default # options are supplied but new functionality may be added by calling... # Instantiate P4Trigger with an instance of OptionParser to modify its # behavior according to options set at the command line. # # The methods supported by the default implementation include: # class P4TriggerOptions def initialize(argv) @argv = argv @options = nil end # Exit with meaningful errors if the options supplied do not match the # requirements specified in the usage help. Override this in subclasses if # special validation of command line options is required. def validate! end # Add an option to the list of supported options. The +short_form+ and # +long_form+ define the option permitted on the command line, the +message+ # parameter is displayed in the help text, and the +method_name+ option # specifies how the value passed in on the option (or true, if none is # required) is retrieved from the P4TriggerOptions object. def add_option(short_form, long_form, message, method_name) end # Returns the results of the options def method_missing(method, *args) parse_options! @options[method] end # # def parse_options! unless @options @options = {} begin option_parser.parse!(@argv) validate! if @options[:verbose] puts "Args:\n", @argv.inspect end rescue Exception => e puts "Invalid command line argument specified." puts "Run ruby #{File.basename($0)} --help for usage." exit 1 end end end private # Defines which options are acceptable. Do not override in subclasses, # use the #add_option hook instead. def option_parser unless @option_parser @option_parser = OptionParser.new do |op| # Usage and help text op.banner = "Usage: ruby #{File.basename($0)} [options]" op.separator "" op.separator "Specific options:" op.on_tail("-h", "--help", "Show this message.") do puts op exit end # Define the command line options indent = (" " * 37) op.on("-c","--client WORKSPACE", "Set the client workspace name, overriding\n" + indent + "the value of $P4CLIENT in the environment.") do |ws| @options[:workspace] = ws # We'll support both names @options[:client] = ws end op.on("-f","--fail", "Run as much of the trigger code as is\n" + indent + "possible, but always exit with an error.") do |f| @options[:fail] = true end op.on("-p","--port PORT", "Set the server's listen address,\n" + indent + "overriding the value of $P4PORT.") do |p| @options[:port] = p end op.on("-P","--passwd P4CONFIG", "Set the name of a file in triggers dir which contains password to use,\n" + indent + "in P4CONFIG format, i.e. P4PASSWD=xxxxx ") do |p| @options[:passwd] = p end op.on("-u","--user USERNAME", "Set the user name, overriding the value of\n" + indent + "$P4USER, $USER, and $USERNAME.") do |u| @options[:user] = u end op.on("-v","--verbose", 'Verbose logging to stdout. For use with -f') do |f| @options[:verbose] = true end op.on("-w","--workspace WORKSPACE", "Set the client workspace name, overriding\n" + indent + "the value of $P4CLIENT in the environment.") do |ws| @options[:workspace] = ws # We'll support both names @options[:client] = ws end end # OptionParser#new end # unless @option_parser end end