#!/usr/bin/ruby # Creates a compact overview of recent changes in an Subversion repository. # # Author:: Martin Ankerl (mailto:martin.ankerl@gmail.com) # Copyright:: Copyright (c) 2006-2009 Martin Ankerl # License:: New BSD License # # Homepage:: https://code.google.com/p/svn-shortlog/ # user configuration BEGIN user_config = { :repository => "http://svn.boost.org/svn/boost", :url => "http://svn.boost.org/svn/boost/trunk", # how to extract a library name from a path. Stops after first regexp matches :lib_regexp => [ /trunk\/libs\/([^\/]*)/, /trunk\/boost\/([^\/]*)/, /trunk\/tools\/([^\/]*)/ ], # replacements for in the content message :msg_gsubs => [ [ /\#(\d+)/, "\\0"] ], # start revision # A revision argument can be one of: # NUMBER revision number # '{' DATE '}' revision at start of the date # 'HEAD' latest in repository # 'BASE' base rev of item's working copy # 'COMMITTED' last commit at or before BASE # 'PREV' revision just before COMMITTED :start_rev => "{2009-12-01}", # stop revision :stop_rev => "{2009-12-31}", # footer :copyright => "Copyright © #{Time.now.year} Martin Ankerl", } # user config END # HTML header with CSS head = <<-'EOF' EOF # here be dragons, modify only if you think you know what you are doing :-) require 'rexml/document' require 'date' require 'iconv' require 'set' # extend Date to get local time (hack for Ruby 1.8) class Date def to_gm_time to_time(new_offset, :gm) end def to_local_time to_time_hack(new_offset(DateTime.now.offset-offset), :local) end private def to_time_hack(dest, method) #Convert a fraction of a day to a number of microseconds usec = (dest.sec_fraction * 60 * 60 * 24 * (10**6)).to_i Time.send(method, dest.year, dest.month, dest.day, dest.hour, dest.min, dest.sec) end end class SvnShortlog include REXML def initialize(user_config, head) @start_time = Time.now @user_config = user_config @head = head end class Entry attr_accessor :author, :time, :paths, :msg, :rev end # h, s, v are between [0, 1[ def hsv_to_rgb(h, s, v) h_i = (h*6).to_i f = h*6 - h_i p = v * (1 - s) q = v * (1 - f*s) t = v * (1 - (1 - f) * s) r, g, b = v, t, p if h_i==0 r, g, b = q, v, p if h_i==1 r, g, b = p, v, t if h_i==2 r, g, b = p, q, v if h_i==3 r, g, b = t, p, v if h_i==4 r, g, b = v, p, q if h_i==5 r = (r*256).to_i g = (g*256).to_i b = (b*256).to_i return sprintf("%02x%02x%02x", r, g, b) end def htmlize(str, gsubs) str = str.gsub("<", "<") str.gsub!(">", ">") gsubs.each do |regexp, replacement| str.gsub!(regexp, replacement) end str end def rev_to_s(str) str = str.gsub(/[{}]/, "") end # creates array of Entry data blob from SVN XML. def parse_xml(doc) data = [] doc.elements.each('log/logentry') do |le| e = Entry.new e.author = le.elements["author"].text e.time = DateTime.parse(le.elements["date"].text).to_local_time e.paths = [] le.elements.each('paths/path') do |pa| e.paths.push [pa.attributes["action"], pa.text] end e.paths.sort! do |a, b| a[1] <=> b[1] end e.msg = le.elements["msg"].text e.msg = htmlize(e.msg, @user_config[:msg_gsubs]) if e.msg e.rev = le.attributes["revision"] data.push e end data end # reformat data blob def restructure(d) r = Hash.new { |h,k| h[k] = [] } d.each do |e| r[e.author].push e end r = r.to_a.sort end def path_to_lib(path) r = @user_config[:lib_regexp].find do |r| path.match(r) end if r m = path.match(r) m[1] else nil end end # create tag cloud using LIB_REGEX based on number of time used def tag_cloud(entries) # collect counts for each library h = Hash.new(0) entries.each do |e| e.paths.each do |kind, path| lib = path_to_lib(path) h[lib] += 1 if lib end end min_size = 11 max_size = 30 max = h.values.max h = h.to_a.sort.map do |lib, count| # s = min_size + count * (max_size - min_size) / max # linear s = (min_size + Math.sqrt(count * max) * (max_size - min_size) / max).to_i # quadratic "#{lib}" end "
#{h.join(" ")}
" end # run everything def run # get data cmd = "svn log #{@user_config[:url]} -r #{@user_config[:start_rev]}:#{@user_config[:stop_rev]} -v --xml" puts "running '#{cmd}'" f = `#{cmd}` data = parse_xml(Document.new(f)) data = restructure(data) # automatically generates colors that are as different as possible. colors = Hash.new do |h, k| @val = 0 unless @val @val += 0.6180339887; # golden ratio @val -= 1 if @val >= 1 h[k] = "##{hsv_to_rgb(@val, 0.4, 0.95)}" h[k] end output_filename = "changes_#{rev_to_s(@user_config[:start_rev])}_to_#{rev_to_s(@user_config[:stop_rev])}.html" puts "creating '#{output_filename}'" #out = STDOUT File.open(output_filename, "w") do |out| out.puts @head # unique id for visibility id = 0 out.puts "

Changes from #{rev_to_s(@user_config[:start_rev])} to #{rev_to_s(@user_config[:stop_rev])}

" # quick link to all authors authors = data.map { |a,e| "#{a} (#{e.size})" } out.puts authors.join(", ") # process all data data.each do |author, entries| # start author out.puts "

#{author} (#{entries.size})

" out.puts tag_cloud(entries) out.puts "
    " # for each commit entries.each do |e| next unless e.msg # start line out.puts "
  1. " # date to the right out.puts "#{e.rev} #{e.time.strftime('%b %d')}" # show colorful used Libs libs = Hash.new {|h,k| h[k] = 0 } e.paths.each do |kind, path| l = path_to_lib(path) libs[l] += 1 if l end libs.to_a.sort.each do |l| out.printf "#{l[0]} #{l[1]} " end # message files = e.paths.map do |kind, path| "#{kind} #{path}" end title = "#{e.rev} by #{e.author} on #{e.time.strftime("%b %d %H:%M")}".gsub(" ", " ") title = "#{title} #{files.join(" ")}" out.puts "#{e.msg.split.join(" ")}" # files files = e.paths.map do |kind, path| "#{File.basename(path)}" end out.puts "#{files.join(", ")}" # initially hidden message details out.puts "
    "
              out.puts "#{e.rev} by #{e.author} on #{e.time.strftime("%b %d %H:%M")}"
              out.print "
    #{e.msg.strip}
    " e.paths.each do |kind, path| out.puts "#{kind} #{path}" end out.puts "
    " out.puts "
  2. " # next ID id += 1 end out.puts "
" end out.puts "


" out.puts @user_config[:copyright] out.puts "
Created in #{Time.now - @start_time} seconds" out.puts "
" end puts "done!" puts end end shortlog = SvnShortlog.new(user_config, head) shortlog.run