#!/usr/bin/perl -w # # $Id: deluxeloginfo,v 2.122 2007/01/26 18:26:29 marco Exp $ # Copyright 2002, 2003, 2004, 2005, 2006 Bernardo Innocenti # # Dedicated to the memory of Gerry, an extraordinary engineer and # a good friend. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # Loosely based on KDE's loginfo.pl: # free of any copyright. Originally written by taj@kde.org with small changes # by coolo@kde.org and daniel.naber@t-online.de # Many improvements and fixes by dirk@kde.org # # # TODO: Coalesce diffs of same file in same changeset with same comment # TODO: Use a table for changeset summaries. # TODO: Show nr. of files and nr. of lines changed in summary. # TODO: Add review checklist below summary. # # use strict; use Getopt::Long; use Pod::Usage; my $versiontag = '$Revision: 2.122 $'; $versiontag =~ s/.*: (.*) .*/$1/; # Command line options my $opt_keeplogs; my $opt_verbose; my $opt_logfile = ''; my $opt_sender = "cvs\@develer.com"; my $opt_recipient = ''; my $opt_outfile = ''; my $opt_prjtab = ''; my $opt_rlog; my $opt_stampdir = '/var/state/loginfo'; my $opt_byauthor; my $opt_maildomain = ''; my $opt_cvsurl = ''; my $opt_bugurl = ''; my $opt_diff; my $opt_html = 1; my $opt_text = 1; my $opt_encoding = 'UTF-8'; my $opt_difflimit = 0; my $opt_startdate; my $opt_enddate; my $opt_root = ''; my $opt_prefix = ''; my $opt_module = ''; my $opt_index = 999; my $opt_indexlines = 1; # Globals my ($is_cvs, $is_svn, $is_git); # # Print a message (arg 1) followed by an optional multi-line preformatted block (arg 2) # sub log_output { if ($opt_verbose) { my ($msg, $text) = @_; print STDERR " $msg" . ($text ? ":" : "") . "\n"; if ($text) { foreach (split '\n', $text) { print " $_\n"; } } } } # # Hold a file-revision pair # package FileRev; # # Construct a FileRev object # # SYNOPSIS: FileRev->new(repodir, filename, r1, r2, tag, date, keywords) # sub new($$$$$$$) { # # Collect arguments and create instance # my $class = shift; my $repodir = shift; my $filename = shift; my $self = { r1 => shift, r2 => shift, tag => shift, date => shift, keywords => shift }; bless $self, $class; # # Normalize filename # # Prune ,v suffix from filename $filename =~ s|,v$||; # Prune CVS repository path from filename $filename =~ s|^$repodir/|| if $repodir; # Prune Attic directory from filename $filename =~ s|/Attic/|/|; # Prune leading '/' at the beginning of the path $filename =~ s|^/||; # Remove any spurious '//' sequences from CVS $filename =~ s|//|/|g; $self->{filename} = $filename; return $self; } # Return normalized filename sub filename { return shift->{filename}; } # Return old file revision sub r1 { return shift->{r1} || "NONE"; } # Return new file revision sub r2 { return shift->{r2} || "NONE"; } # Return the branch tag for the file sub tag { return shift->{tag} || "HEAD"; } # Return commit timestamp sub date { return shift->{date}; } # Tell if this file is binary sub is_binary { return shift->{keywords} eq "kb"; } # # Handle a list of changed files, the associated log message and the author # package ChangeSet; # Construct a new ChangeSet. Arg1 is the author, arg2 is the log message sub new($$$) { my $class = shift; bless { author => shift, log => shift, files => [], tag => "HEAD", date => "1970/01/01 00:00:00" }, $class; } # Add a new FileRev entry sub add { my ($self, $filerev) = @_; push @{$self->{files}}, $filerev; # Collect date of newest change $self->{date} = $filerev->{date} if ($self->{date} lt $filerev->{date} ); # Collect tag - FIXME: only one tag $self->{tag} = $filerev->tag; } # Retrieve the author name sub author { return shift->{author}; } # Retrieve the log message sub log { return shift->{log}; } # Return the branch tag for the file sub tag { return shift->{tag} || "HEAD"; } # Return an array of FileRev objects sub files { my @fr = @{shift->{files}}; # Return base filename and extension sub split_extension($) { my $name = shift; $name =~ /^(.*)\.([^.\/]*)$/; return ($1 || $name, $2 || ""); } sub is_header($) { shift =~ /^h|hh|hxx|hpp|h\+\+$/ } sub is_source($) { shift =~ /^c|cpp|cc|cxx|C|c\+\+$/ } # Fancy sort that makes headers appear before sources, to make code # reviews somewhat easier. sort { my $name_a = $a->filename(); my $name_b = $b->filename(); # Split into basename + extension my ($base_a, $ext_a) = split_extension($name_a); my ($base_b, $ext_b) = split_extension($name_b); #DEBUG #print "base_a=$base_a, ext_a=$ext_a, base_b=$base_b, ext_b=$ext_b\n"; # Same basename and non-null extensions? if ($base_a eq $base_b) { # Sort headers before implementations return -1 if is_header($ext_a) && is_source($ext_b); return +1 if is_header($ext_b) && is_source($ext_a); } # Sort normally return $name_a cmp $name_b; } @fr; } # # Maintain a collection of ChangeSets sorted by authors and log messages. # package CSList; sub new($$$) { my $class = shift; bless { authors => {} }, $class; } # # Add a new FileRev entry to an existing ChangeSet. # If no ChangeSet exists for the given log, create a new one. # sub add_filerev { my ($self, $author, $log, $filerev) = @_; my ($changeset, $l); # Complain for missing log entries $log = "*** Empty log message! ***" if !$log; # Do we know this author yet? if (!($l = $self->{authors}{$author})) { main::log_output("Create new author $author"); $l = $self->{authors}{$author} = {}; } # Do we know this log yet? if (!($changeset = ${$l}{$log})) { main::log_output("Create new ChangeSet for $author", $log); $changeset = ChangeSet->new($author, $log); ${$l}{$log} = $changeset; } main::log_output("Append new file for $author on " . $filerev->filename . " rev " . $filerev->r1 . " -> " . $filerev->r2); $changeset->add($filerev); } # # Construct a FileRev entry and add it with CSList::add_filerev() # # SYNOPSIS: cs_list->add_file(author, log, repodir, file, r1, r2, tagname, date, keywords) # sub add_file { my $self = shift; my $author = shift; my $log = shift; my $filerev = FileRev->new(@_); $self->add_filerev($author, $log, $filerev); } # # Return a list containing all author names. # sub authors { return keys %{shift->{authors}}; } # # Return a list containing all ChangeSet objects for a specified author # the list is sorted in ascending date order. # sub changesets { my ($self, $author) = @_; return sort { $a->{date} cmp $b->{date} } values %{$self->{authors}{$author}}; } # # Main program # package main; sub add_to_cc(\$$) { my ($cc, $email) = @_; $$cc .= length($$cc) ? ", $email" : "$email" if($$cc !~ /$email/); } # include first file in second file sub append_file($$) { local $/; # enable localized slurp mode open(my $in, '<', shift) or die $!; open(my $out, '>>', shift) or die $!; print {$out} <$in>; } # Create empty file with specified owner sub create_empty_file($;$$) { my $filename = shift; open(FILE, ">$filename") or die $!; close FILE; if (my $user = shift) { my (undef,undef,$uid,$gid) = getpwnam($user) or die $!; chown $uid, $gid, $filename; if (my $mode = shift) { chmod $mode, $filename; } } } # # Return date of a file argument in RFC2822 format (the format CVS understands), # or undef if file couldn't be stat'd. # sub rfc2822_date($) { use Date::Format; use File::stat; my $st = stat(shift) or return undef; return time2str('%a, %d %b %Y %H:%M:%S %z', $st->mtime, 'UTC') } # # Generate unique filename from the repository root and module. # sub stamp_filename($) { my $module = shift; my $stamp = $opt_root; $stamp .= "/$module" if $module; $stamp =~ tr|:\/\\@.|_____|; $stamp .= ".stamp"; $stamp = "$opt_stampdir/$stamp"; return $stamp; } # # Return a date range in the format OLDmtime; } else { $mtime = time - 60*60*24; } return time2str('{%Y-%m-%d %H:%M:%S %z}', $mtime, "UTC") } # # Return an SVN date or revision range in the format "{OLD}:{NEW}" or # "R1:R2" from the given stamp file. # sub svn_date_range($$) { my ($module, $stamp) = @_; my $dates; if ($opt_startdate) { $dates = "{$opt_startdate}"; } else { # Get timestamp from file, touch it and get filestamp again # (diff since yesterday if no previous stamp file is present). $dates = svn_date($stamp); } $dates .= ":"; if ($opt_enddate) { $dates .= "{$opt_enddate}"; } else { # Create a new file with the current datestamp # (we'll overwrite the old stamp later, only on success) if (! -d $opt_stampdir) { print STDERR "Creating $opt_stampdir since it doesn't exist\n"; mkdir $opt_stampdir || die "Can't create $opt_stampdir: $!\n"; } create_empty_file("$stamp.new"); svn_save_revision("$stamp.new", svn_last_changed($module)); #svn_last_rev($module)); $dates .= svn_date("$stamp.new"); } return $dates; } # # module, field -> revision # Get specified field from svn info output: # # Path: . # [...] # Last Changed Author: sally # Last Changed Rev: 11754 # Last Changed Date: 2007-01-16 12:34:13 +0100 (Tue, 16 Jan 2007) # [...] # # TODO: use xml. # sub svn_info($$) { my ($module,$field) = @_; open my $svninfo, "svn info --non-interactive $opt_root/$module 2>/dev/null |" or die; while (<$svninfo>) { if (/^$field: (\d+)/) { close $svninfo; return $1; } } close $svninfo; } # # module -> last changed revision # sub svn_last_changed($) { return svn_info(shift, "Last Changed Rev"); } # # module -> head # sub svn_head($) { return svn_info(shift, "Revision"); } # # timestamp_filename -> revision # If timestamp file does not exist or it's empty, return -1. # sub svn_rev($) { my $rev; if (open my $revision, shift) { chomp($rev = <$revision>); close $revision; return ($rev =~ /\d+/) ? $rev : -1; } return -1; } # # Write last committed revision in timestamp file. # sub svn_save_revision($$) { my ($filename, $last_rev) = @_; open my $revision, ">$filename" or die; print $revision "$last_rev"; close $revision; } # # module, timestamp_filename -> revision (or date) range # Build argument for svn's -r option. If last changeset revision isn't avaible, # returns date range (see svn_date_range). # If last revision is HEAD return 0. # sub svn_rev_range($$) { my ($module, $filename) = @_; my $last_diff = svn_rev($filename); my $head = svn_head($module); if ($last_diff == $head) { return 0; } my @rev = ($last_diff + 1, $head); # get date range and update revision in file # TODO: should be splitted in two functions my $date_range = svn_date_range($module, $filename); return $rev[0] > 0 ? join(":", @rev) : $date_range; } sub bugzillate($) { return unless $opt_bugurl; # "Bug 123", "bug #123", or just "#123" $_[0] =~ s{((?:[Bb]ug\s*(?:#|)|#)\s*)(\d{2,6})}{$1$2}g; } # # Print a multi-part MIME header # sub mime_header(*) { my $fh = shift; print $fh <--
Generated by Deluxe Loginfo $versiontag by Bernardo Innocenti <bernie\@develer.com>

EOF unhtml($sig) if $toascii; print $fh $sig; } # # Print HTML header # sub html_header(*$) { my $fh = shift; my $title = shift; print $fh < $title EOF } # # Print HTML footer # sub html_footer(*) { my $fh = shift; print $fh < EOF } sub send_mail(\$\$\$$$$$) { my ($text, $html, $summary, $module, $to, $cc, $author) = @_; # Avoid sending empty mails if (!$$text) { print STDERR "Not sending empty mail.\n" if $opt_verbose; return; } # Count the change sets in the summary my @foo = split /^/, $$summary; if (!defined($opt_index) || (($opt_index > 0) && @foo <= $opt_index)) { $summary = ""; } else { # Complete the summary $summary = "

ChangeSet Index:

\n
    \n" . $$summary . "
\n"; bugzillate $summary; } # Build subject line my $subject = $author ? "changes by $author" : "change log"; $subject .= " for $module" if ($module); { # Compute current time my (undef , undef, undef, $mday, $mon, $year, undef, undef, undef) = localtime; $year += 1900; $mon += 1; $subject .= sprintf(" (%04d-%02d-%02d)", $year, $mon, $mday); } my $from; if ($author and $opt_maildomain) { $from = "$author <$author\@$opt_maildomain>"; } else { $from = "$opt_sender <$opt_sender>"; } my $newsgroups = ""; # Force recipient if set if ($opt_recipient) { $to = $opt_recipient; } # Strip "mailto:" (the default) if ($to =~ /^mailto:(.+)/) { $to = $1; } # Match newsgroups elsif ($to =~ /^news:(.+)/) { $newsgroups = $1; $to = ""; } if ($opt_outfile) { print STDERR "Writing mail to '$opt_outfile'...\n" if $opt_verbose; open (MAIL, ">>$opt_outfile") or die "Couldn't open outfile: $!"; } elsif ($newsgroups) { print STDERR "Posting article in '$newsgroups'...\n" if $opt_verbose; open (MAIL, "|/usr/bin/inews -h") or die "Couldn't exec inews: $!"; } elsif ($to) { print STDERR "Sending mail to <$to>...\n" if $opt_verbose; open (MAIL, "|/usr/lib/sendmail -t") or die "Couldn't exec sendmail: $!"; } else { print STDERR "No recipient, discarding mail.\n" if $opt_verbose; open (MAIL, ">/dev/null") or die "Couldn't open /dev/null: $!"; } print MAIL "From: $from\n"; print MAIL "To: $to\n" if ($to); print MAIL "Cc: $cc\n" if ($cc); print MAIL "Newsgroups: $newsgroups\n" if ($newsgroups); print MAIL "Subject: $subject\n"; # # Our intention here is to generate a plain message when # no HTML output is present and a MIME multi-part message # when we have HTML output alone or along with ASCII output. # mime_header(*MAIL) if $opt_html; # End of mail headers print MAIL "\n"; if ($opt_text && $opt_html) { mime_part(*MAIL, 'text/plain'); } if ($opt_text) { print MAIL unhtml($summary); print MAIL $$text; signature(*MAIL, 1); } if ($opt_html) { mime_part(*MAIL, 'text/html'); html_header(*MAIL, $subject); print MAIL $summary; print MAIL $$html; signature(*MAIL, 0); html_footer(*MAIL); mime_footer(*MAIL); } close(MAIL); # Clear buffers $$text = ""; $$html = ""; } sub dodiff($$$$) { my ($module, $file, $r1, $r2) = @_; my $copts = ""; my $is_diff = 1; # Regex to match source files (doesn't work with cvs rdiff) #my $srcfile = '\.(cpp|cc|cxx|C|c\+\+|c|l|y|h|hh|hxx|hpp|h\+\+|pl|jsp|java|py|sh)$'; #$copts = "dpbB" if ($file =~ /$srcfile/); # Avoid diffs for removed files return "" if ($r2 eq "NONE"); my @args; if ($is_cvs) { # Prepend module name to file name $file = "$module/$file" if $module; # Workaround for first revision of added files (does only work with rdiff!) $r1 = "1.0" if ($r1 eq "NONE"); @args = ('cvs', '-Qfn', "-d$opt_root", 'rdiff', '-kk', "-u$copts", "-r$r1", "-r$r2", $file); } elsif ($is_svn) { if ($r1 eq "NONE") { @args = ('svn', 'cat', "-r$r2", "$opt_root/$file\@$r2"); $is_diff = 0; } else { @args = ('svn', 'diff', "-r$r1:$r2", "$opt_root/$file\@$r2"); } } print STDERR "+ " . join(' ', @args) . "\n" if $opt_verbose; open(DIFF, "-|") || exec @args; my $cnt = 0; my $hunk = ""; my $interesting = 0; my $size_limit = 0; my $diff = ""; if (!$is_diff) { $hunk .= ("=" x 67) . "\n"; $hunk .= "--- /dev/null\n"; $hunk .= "+++ $file\t(revision $r2)\n"; } while() { next if /^Index:/; $cnt++; # Truncate diff when it exceeds # or if the size grows over full lines of 80 characters. if ($opt_difflimit and (($cnt > $opt_difflimit) or (length($diff) > $opt_difflimit * 80))) { # Limit exceeded: discard further input and exit while () {} $size_limit = 1; last; } if ($is_diff) { # Begin of a new hunk? if (/^@@/) { # Consider the hunk only if it wasn't boring $diff .= $hunk if $interesting; $hunk = ""; $interesting = 0; } # A change that doesn't only contain keywords output is interesting. $interesting++ if /^[\-\+]/ && !/\#\*\#/ && !/\*\#\*/ && !/\$(Version|Revision|Id|Log):/; # Subversion doesn't print the full pathname with remote diffs s{(\+\+\+|---) [^/]+\t(.*revision.*)}{$1 $file\t$2}; } else { # Make it look like a diff $hunk .= "+"; } $hunk .= $_; } close(DIFF); # Notify diff errors $? and $diff .= "*** DIFF FAILED: $! ***"; # Pick last hunk too $diff .= $hunk if ($interesting || !$is_diff); $size_limit and $diff .= "*** SIZE LIMIT EXCEEDED - DIFF TRUNCATED ***"; $diff .= "\n" if length($diff); return $diff; } # # Escape characters with special meaning for HTML in the passed string(s). # sub htmlize { foreach (@_) { s/&/&/g; s//>/g; } } # # Remove HTML tags from the passed string(s), trying to retain some formatting. # # Note: When called in void context, arguments are modified in-place, # otherwise unhtml() returns the translated strings. # sub unhtml { my @copy; # Copy arguments if caller expects a result foreach (defined(wantarray) ? @copy = @_ : @_) { # Replace hyperlinks with plain-text versions s#(.*?)#$3 [$1]#g; # Break paragraphs s##\n#g; s#

#\n#g; # Remove all tags s#<(.*?)>##g; # Translate some commonly used entities s#&#&#g; s#<#<#g; s#>#>#g; } return defined(wantarray) ? @copy : undef; } sub diff2html($) { my ($newstyle, $oldstyle) = ("", ""); my $out = "
\n";

	foreach (split /\n/, shift)
	{
		# Remove trailing \n
		chomp;

		# Replace HTML reserved chars with entities
		htmlize $_;

		# Mark leading CRs at EOL
		s/\r$/<CR><\/span>/;

		# Mark leading whitespace at EOL
		s/([^ ])(\s+)$/$1$2<\/span>/;

		# Mark hidden whitespace before TABs
		s/([^ ])( +)\t/$1$2<\/span>\t/;

		# Replace long runs of '===' with a horizontal ruler
		s#^={30,}#
#; SWITCH: { /^(--- |\+\+\+ |diff)/ && do { $newstyle = "006600"; last SWITCH }; /^\+/ && do { $newstyle = "000088"; last SWITCH }; /^-/ && do { $newstyle = "880000"; last SWITCH }; /^@@/ && do { $newstyle = "997700"; last SWITCH }; /^\*\*\*/ && do { $newstyle = "FF0000"; last SWITCH }; $newstyle = ""; } # Optimization: avoid printing redundant ... pairs if ($newstyle ne $oldstyle) { $out .= "" if ($oldstyle); $out .= "" if ($newstyle); $oldstyle = $newstyle; } $out .= "$_\n"; } $out .= "" if ($oldstyle); $out .= "
\n"; return $out; ### BEGIN: EXPERIMENTAL/UNUSED CODE ### # Fork my $pid = open (ENSCRIPT_OUTPUT, "-|"); die ("can't fork") unless defined($pid); if (!$pid) { # In child: pass text diff to enscript and die open (ENSCRIPT_INPUT, "|-") || exec 'enscript', '--color', '-Whtml', '-Ediffu', '-o', '-', '-'; print ENSCRIPT_INPUT shift; exit 0; } else { # In parent: collect enscript output my $out = ""; while() { $out .= $_ if (/^
/ .. /<\/PRE>/)
		}
		return $out;
	}
### END: EXPERIMENTAL/UNUSED CODE ###
}

sub format_output($$$\$)
{
	my ($csnum, $changeset, $module, $ccrecipients) = @_;
	my ($fmtText, $fmtHtml, $summary);
	my $diff = "";
	my @files = $changeset->files;
	my $author = $changeset->author;
	my $log = $changeset->log;
	my $tagname = $changeset->tag;

	log_output("Processing ChangeSet by $author of " . scalar @files . " files.");

	# Do nothing for empty file lists
	if (@files == 0)
	{
		log_output("Skipping empty ChangeSet");
		return ();
	}

	if ($log =~ /((CVS|SVN).?SILENT)/)
	{
		# Ignore this commit and reset silent status
		log_output("Ignoring commit by $author because of '$1'", $log);
		return ();
	}

	my $incipit = "";
	my $incipit_lines = 0;
	foreach (split '\n', $log)
	{
		if (/^CCMAIL:\s*(.*)\s*$/)
		{
			add_to_cc($ccrecipients, $1);
			next;
		}

		# Collect first lines
		if ($incipit_lines < $opt_indexlines)
		{
			$incipit .= '
' if $incipit; htmlize $_; $incipit .= $_; } elsif ($incipit_lines == $opt_indexlines and $opt_indexlines != 0) { $incipit .= '[...]'; } $incipit_lines++; } $summary .= "
  • CS$csnum - $incipit
  • \n"; $fmtHtml .= "\n"; $fmtHtml .= "\n"; unless ($opt_byauthor and $tagname eq "HEAD") { $fmtHtml .= "\n"; $fmtText .= ":\n"; } $fmtText .= "$log\n"; htmlize $log; bugzillate $log; $fmtHtml .= "\n"; foreach my $filerev (@files) { my $file = $filerev->filename(); my $r1 = $filerev->r1(); my $r2 = $filerev->r2(); # Guess CVS operation from revisions pair my $state = 'M'; my $added_state = 'A'; my $removed_state = $is_svn ? 'D' : 'R'; $state = $removed_state if ($r2 eq "NONE"); $state = $added_state if ($r1 eq "NONE"); # Find out a "good" revision to link to my $rev = ($r2 eq "NONE") ? $r1 : $r2; my $binary_text = ""; $binary_text = "[BIN] " if ($filerev->is_binary); $fmtText .= sprintf("$state %6s $binary_text$file\n", $rev); my $statelink = $state; if ($opt_cvsurl) { # Prepend a slash to the module name if needed, define to an empty string otherwise my $module_path = ""; $module_path = "/$module" if $module; if ($state eq 'M') { $statelink = "$state"; } else # 'R' or 'A' { $statelink = "$state"; } } my $color = ""; my $color_end = ""; if ($state eq 'A') { $color = '' }; if ($state eq 'R') { $color = '' }; $color_end = "" if $color; $fmtHtml .= "\n"; if ($opt_diff && !$binary_text) { $diff .= dodiff($module, $file, $r1, $r2); } } $fmtHtml .= "
    \n"; unless ($opt_byauthor) { $fmtHtml .= " $author\n"; $fmtText .= " *$author*"; } unless ($tagname eq "HEAD") { $fmtHtml .= " (on branch $tagname)\n"; $fmtText .= " (on branch $tagname)"; } $fmtHtml .= "
    $log
    $statelink$color$rev$color_end$binary_text$color$file$color_end
    \n"; # Put some spacing between the file list and the diff $fmtText .= "\n"; if (length($diff)) { $fmtText .= $diff . "\n"; $fmtHtml .= diff2html($diff); } # Some spacing $fmtHtml .= "

     

    \n"; return ($fmtText, $fmtHtml, $summary); } # # Process accumulated logs # sub process_cslist($$$) { my ($cslist, $module, $recipient) = @_; my $fmtText = ""; my $fmtHtml = ""; my $summary = ""; my $ccrecipients = ""; my $csnum = 1; foreach my $author ($cslist->authors) { foreach my $changeset ($cslist->changesets($author)) { if (my @out = format_output($csnum, $changeset, $module, $ccrecipients)) { $fmtText .= $out[0]; $fmtHtml .= $out[1]; $summary .= $out[2]; $csnum++; } } if ($opt_byauthor) { send_mail($fmtText, $fmtHtml, $summary, $module, $recipient, $ccrecipients, $author); # Reset accumulated output $fmtText = $fmtHtml = $summary = ""; $ccrecipients = ""; $csnum = 1; } } if (!$opt_byauthor) { send_mail($fmtText, $fmtHtml, $summary, $module, $recipient, $ccrecipients, undef); } } # # Helper for cvs_rlog(). # Parse a CVS revision number and return the best guess for its ancestor. # sub guess_ancestor($) { my ($rev) = @_; my $old; # New files don't have an ancestor return undef if $rev =~ /1\.1$/; # Parse revision numbers such as 1.34 or 1.7.2.42 # Assign the radix (i.e.: 1.7.2) and the minor (i.e.: 42) if (my ($radix, $minor) = ($rev =~ /^([\d.]+)\.(\d+)$/)) { # Guess the ancestor: usually one less than the new revision --$minor; $old = "$radix.$minor"; # If the ancestor's minor revision was 0, perhaps we # were working with the first revision of a branch. # # We can't just diff between version 1.9.2.0 and # 1.9.2.1, because the former doesn't exist and CVS # would diff against /dev/null. # # The correct ancestor in this example would be 1.9. # if ($minor <= 0) { ($old) = ($radix =~ /^([\d.]+)\.\d+$/); } } unless (defined($old)) { print STDERR "*** Warning: can't parse revision '$rev'\n"; } return $old; } # # Process commitlog files # sub cvs_commitlog($$) { my ($module, $commitlog) = @_; # initialize with the default values my $tagname = ""; my $juststarted = 1; my $beforelogmsg = 1; my $directory = ""; my $author = ""; my $logmessage = ""; my $date = "1970/01/01 00:00:00"; my @files = (); my $cslist = CSList->new(); open COMMITLOG, "< $commitlog" or die "couldn't open '$commitlog': $!\n"; while() { if ($beforelogmsg) { # Skip empty lines next if /^\s*$/; chomp; if (/^Dir: \s*(.*)$/) { $directory = $1; } elsif (/^File: \s*(.+)$/) { push @files, $1; } elsif (/^Author: \s*(.+)$/) { $author = $1; } elsif (/^Date: \s*(.+)$/) { $date = $1; } elsif (/^\s*Tag: \s*(\S+)$/){ $tagname = $1; } elsif (/^Log Message:/) { $beforelogmsg = 0; } elsif (/^(\S*) (.*)$/ && $juststarted) { # This is for old-style file list (pre CVS 1.12) $directory = $1; $_ = $2; # Ignore: # "dir - New Directory" # "dir - Imported sources" next if /^- /; while (/^([^,]+,(?:NONE|[\d\.]+),(?:NONE|[\d\.]+)) ?(.*)/) { push @files, $1; $_ = $2; } } $juststarted = 0; } else { if (/^---END---$/) { # Remove newlines at end of log while (chomp $logmessage) {} foreach my $f (@files) { next if !length($f); next if $f =~ /^- /; # skip "- New Directory" or "- Imported Sources" my ($file, $r1, $r2) = $f =~ /(.+),(.+),(.+)/; my $relfile = $directory ? "$directory/$file" : $file; # FIXME: no way to tell if a file is binary? $cslist->add_file($author, $logmessage, "", $relfile, $r1, $r2, $tagname, $date, 'kv'); } # Restart @files = (); $directory = ""; $logmessage = ""; $juststarted = 1; $beforelogmsg = 1; $tagname = ""; $date = "1970/01/01 00:00:00"; } else { # Collect log message $logmessage .= $_; } } } close COMMITLOG; # Rotate log file if (!$opt_keeplogs) { my $oldlog = $commitlog; if ($commitlog =~ /^(.*)\.new$/) { $oldlog = $1; } else { $oldlog = "$commitlog.old"; } append_file($commitlog, $oldlog); create_empty_file($commitlog, "cvs", 0660); } return $cslist; } # # Process cvs rlog output # sub cvs_rlog($$) { my ($module, $commitlog) = @_; my $stamp; # # Extract path prefix from the root # my $cvsdir; if ($opt_prefix) { $cvsdir = $opt_prefix; } elsif ($opt_root) { if ($opt_root =~ /^\//) { $cvsdir = "$opt_root/$module"; } elsif ($opt_root =~ /^:[^:@]*:[^:@]*@[^:@]*:([^:@]+)$/) { $cvsdir = $1; } } print STDERR "Reading rlog with cvsdir='$cvsdir'\n" if ($opt_verbose); if ($commitlog) { open RLOG, "< $commitlog" or die "couldn't open '$commitlog': $!\n"; } else { $stamp = stamp_filename($module); my $date_range = cvs_date_range($stamp); # NOTE: $module may be an empty string, which is fine with CVS # as it means "fetch the repository root". my @args = ('cvs', '-Qfn', "-d$opt_root", 'rlog', "-d$date_range", "$module"); print STDERR "+ " . join(' ', @args) . "\n" if $opt_verbose; open(RLOG, "-|") || exec @args; } # A nested data structure to hold all collected log data my $cslist = CSList->new; # initialize vars with the default values my $file = ""; my $r2 = ""; my $log = ""; my $directory = ""; my $date = ""; my $author = ""; my $removed = 0; my $keywords = ""; my %rev_tags; # Parser states: # 1 - parsing file header # 2 - parsing revision log # 3 - parsing revision message my $state = 1; LOOP: while() { if ($state == 1) # parse the header { if (/^RCS file: (.*)$/) { $file = $1; %rev_tags = (); } elsif (/^keyword substitution: (.*)$/) { $keywords = $1; } elsif (/^symbolic names:/) { # Collect list of branches and tags: # # LONDON_DEMO: 1.23 # RELEASE_1_0: 1.14 # RELEASE_1_0_PATCHES: 1.23.0.6 # while() { # Extract the branch name and its revision if (my ($tag, $r) = /^\t(.*): (.*)$/) { my @rev = split(/\./, $r); # Revisions such as "1.27" are obviously not branches next if @rev <= 2; # Branch tags have the second last revision number # fixed to 0 (e.g.: 1.23.0.6) next unless ($rev[$#rev - 1] == 0); # Chop away the second last revision number @rev = @rev[0 .. ($#rev-2), $#rev]; # Rebuild the branch revision (e.g.: 1.23.6) $r = join('.', @rev); # Create both a direct and reverse # mapping to retrieve branches. $rev_tags{$r} = $tag; } else { # bail out on end of tag list, # reprocessing last input as a valid keyword. redo LOOP; } } } elsif (/^---/) { $state = 2; $author = "UNKNOWN"; $removed = 0; } } elsif ($state == 2) # parse revision log { # EXAMPLE: date: 2003/11/26 19:46:57; author: codewiz; state: dead; lines: +0 -0 if (/^date: *([^;]*); *author: *([^;]*); *state:([^;]*);/) { $date = $1; $author = $2; $removed = ($3 =~ /dead/) ? 1 : 0; $state = 3; } elsif (/^revision (.*)/) { # This is the new revision $r2 = $1; } elsif (/^===/) { $state = 1; } } elsif ($state == 3) # parse log message { # End of log message? if (/^---/ or /^===/) { # Compute old/new revision numbers my $r1 = guess_ancestor($r2); undef $r2 if $removed; # Try to match the file revision with a branch tag # Make sure we have a branch revision number # which is composed by at least 4 components. # # We throw away the minor revision number which # we don't need in order to match the branch number. # my ($tagname, $r); $r = $r2 || $r1; if ((($r) = ($r =~ /^(\d+(?:.\d+){2,})\.\d+$/))) { $tagname = $rev_tags{$r} || "UNKNOWN"; } $cslist->add_file($author, $log, $cvsdir, $file, $r1, $r2, $tagname, $date, $keywords); $log = ""; $state = 2; $state = 1 if /^===/; } else { # Append log line $log .= $_; } } } # end while(<>) close RLOG; die "Reading cvs rlog failed: $!" if $?; # Commit date for next log rename "$stamp.new", "$stamp" if ($stamp); return $cslist; } # # Read svn log in XML format. # svn log command, repository path -> CSList # Output tree (svn log -v --xml): # # log # logentry # revision # author # date # msg # paths # path # action # content # [copyfrom-path] # [copyfrom-rev] # sub svn_xml_log($$) { use XML::Simple; use Data::Dumper; my ($cmd, $repodir) = @_; print STDERR "+ " . join(' ', @{$cmd}) . "\n" if $opt_verbose; open (my $fh, "-|") || exec @{$cmd}; my $log = XMLin($fh, ForceArray => [ 'logentry', 'path' ]); my $cslist = CSList->new; print '*'x79 ."\n" . Dumper($log) . '*'x79 ."\n" if $opt_verbose; foreach my $logentry (@{$log->{logentry}}) { foreach my $paths (values (%{$logentry->{paths}})) { foreach my $path (@{$paths}) { my $rev = $path->{action} eq "D" ? undef : $logentry->{revision}; my $prev = $path->{action} eq "A" ? exists $path->{'copyfrom-rev'} ? $path->{'copyfrom-rev'} : undef : $logentry->{revision} - 1; $cslist->add_file($logentry->{author}, $logentry->{msg}, $repodir, $path->{content}, $prev, $rev, "", # branch $logentry->{date}, ""); # keywords } } } return $cslist; } # # Process svn log output # sub svn_rlog($$) { my ($module, $commitlog) = @_; my $stamp; my $skip_first_cs; # # Extract path prefix from the root # my $repodir; if ($opt_prefix) { $repodir = $opt_prefix; } else { $repodir = $module; } # elsif ($opt_root and $opt_root =~ m{^[\w\+]+://((?:[-\w\.]+/*)+)$}) # { # $repodir = $1; # } $stamp = stamp_filename($module); my $range = svn_rev_range($module, $stamp); if (! $range) { return CSList->new; } my @args = $commitlog ? ('cat', "$commitlog") : ('svn', 'log', '--non-interactive', '-v', '--xml', "-r$range", "$opt_root/$module"); my $cslist = svn_xml_log(\@args, $repodir); rename "$stamp.new", "$stamp" if ($stamp); return $cslist; } sub dolog($$$) { my ($module, $commitlog, $recipient) = @_; my $cslist; if ($opt_verbose) { print STDERR "Processing changelog"; print STDERR " from '$commitlog'" if $commitlog; print STDERR " on repository '$module'" if $module; print STDERR "...\n"; } if ($opt_root) { # Match ":protocol:user@host:path" $is_cvs = ($opt_root =~ m{^:[^:@]*:[^:@]*@[^:@]*:[^:@]+$}); # Match "protocol://path" $is_svn = ($opt_root =~ m{^[\w\+]+://(\w+@|)(/|)(?:[-\w\.]+/*)+$}); # Match "git" - FIXME: just a placeholder $is_git = ($opt_root =~ m{^git}); # None? Try to guess from local repository if (!($is_cvs or $is_svn or $is_git)) { # Maybe it's a local path, try to determine whether it's CVS or SVN $is_cvs = (-d "$opt_root/CVSROOT"); $is_svn = (-f "$opt_root/format"); #TODO: add git print STDERR "Root appears to be a CVS local repository.\n" if $opt_verbose and $is_cvs; print STDERR "Root appears to be a SVN local repository.\n" if $opt_verbose and $is_svn; } # Multiple matches? Something weird must have happened... $is_cvs ||= 0; $is_svn ||= 0; $is_git ||= 0; if (($is_cvs + $is_svn + $is_git) > 1) { die "Ambiguous root path '$opt_root'" }; } else { # For now, we only support CVS with commitlog $is_cvs = 1; print STDERR "Assuming CVS since no --root given.\n" if $opt_verbose; } if ($is_cvs) { if ($opt_rlog) { $cslist = $opt_rlog ? cvs_rlog($module, $commitlog) : cvs_commitlog($module, $commitlog); } else { $cslist = cvs_commitlog($module, $commitlog); } } elsif ($is_svn) { $opt_rlog or die "SVN only supported through --rlog"; # FIXME: use svn info to infer $opt_module or die if --root does not match repository root $cslist = svn_rlog($module, $commitlog); } elsif ($is_git) { # Abort silently to avoid complaints from cron return; } else { die "Malformed root path: $opt_root"; } process_cslist($cslist, $module, $recipient); } # # MAIN ENTRY POINT # # Make sure that CVS doesn't use "/root" as $HOME delete $ENV{'HOME'}; # Make sure SVN and CVS both run with utf8 encoding $ENV{'LANG'} = 'en_US.UTF-8'; # Use SSH by default with the :ext: protocol $ENV{'CVS_RSH'} = "ssh"; unless (GetOptions( 'o|outfile=s' => \$opt_outfile, 'sender=s' => \$opt_sender, 'maildomain=s' => \$opt_maildomain, 'to|recipient=s'=> \$opt_recipient, 'by-author' => \$opt_byauthor, 'diff' => \$opt_diff, 'difflimit=n' => \$opt_difflimit, 'index:n' => \$opt_index, 'index-lines:n' => \$opt_indexlines, 'html!' => \$opt_html, 'text|ascii!' => \$opt_text, 'encoding=s' => \$opt_encoding, 'module=s' => \$opt_module, 'prjtab=s' => \$opt_prjtab, 'rlog' => \$opt_rlog, 'stampdir=s' => \$opt_stampdir, 'startdate=s' => \$opt_startdate, 'enddate=s' => \$opt_enddate, 'keeplogs' => \$opt_keeplogs, 'root|cvsroot=s'=> \$opt_root, 'prefix|cvsprefix=s' => \$opt_prefix, 'cvsurl=s' => \$opt_cvsurl, 'bugurl=s' => \$opt_bugurl, 'v|verbose+' => \$opt_verbose, 'h|help' => sub { pod2usage(1); }, 'man' => sub { pod2usage(-exitstatus => 0, -verbose => 2); }, )) { print STDERR "Try '$0 --help' for more information.\n"; exit(1); } if (($opt_prjtab || $opt_rlog) && @ARGV) { print STDERR "ERROR: input file arguments are mutually exclusive with --rlog and --prjtab modes.\n"; pod2usage(1); } if ($opt_prjtab) { print STDERR "Opening prjtab file: $opt_prjtab...\n" if $opt_verbose; open PRJTAB, $opt_prjtab or die "couldn't open $opt_prjtab: $!\n"; while () { chomp; # Skip comments and blank lines next if /^#/ or /^\s*$/; # NOTE: email hogs all remaining fields my ($prjname, $root, $email) = split(' ', $_, 3); if (!$prjname || !$root || !$email) { die "$opt_prjtab:$.: Illegal prjtab format."; } my $commitlog; if ($opt_rlog) { # Use directory as repository root, unless overridden by user $opt_root = $root; # Strip away repository path #$opt_prefix = "$root/$prjname"; } else { $commitlog = "$root/commitlog.new" if !$opt_rlog; } dolog($prjname, $commitlog, $email); } close PRJTAB; } elsif ($opt_rlog) { if (!$opt_root) { print STDERR "ERROR: --rlog specified and no --root given.\n"; pod2usage(1); } # Fetch from SCM dolog($opt_module, undef, $opt_recipient); } else { if (!@ARGV) { print STDERR "ERROR: no log files provided as input.\n"; pod2usage(1); } # Process command line arguments while(my $logfile = shift @ARGV) { dolog($opt_module, $logfile, $opt_recipient); } } __END__ =head1 NAME deluxeloginfo - Generate commit logs for CVS or SVN =head1 SYNOPSIS deluxeloginfo [OPTION]... [FILE]... Options to specify the repository: --root=ROOT base repository path --prefix=DIR Repository prefix to strip away in logs --module=NAME CVS module or subtree to work on --prjtab=FILE use FILE as a prjtab file to find repositories --rlog use remote repository access instead of loginfo files --stampdir=DIR where to store timestamps for cvs rlog and svn log (default: /var/state/loginfo) --startdate=DATE specify start date instead of picking it from stamp file --enddate=DATE specify end date instead of picking it from stamp file --keeplogs keep log files instead of rotating them Options for output redirection: -o, --outfile=FILE use FILE as output instead of sending mail --sender=ADDR sender e-mail address for log messages --maildomain=DOMAIN mail domain for committers (used for From:) --to=ADDR comma-separated list of mail recipients or newsgroups (devtools@lists.develer.com or news:comp.lang.c) Options for output format: --by-author send one mail for each committer --diff show diff for commits (requires --root) --difflimit=N show up to N lines of diff output --index[=N] output a summary of ChangeSets. If N is specified, the index is printed only when it contains at least N ChangeSets. Set to 0 to always print the index --index-lines=N Number of log lines in an index entry (default: 1) --notext Disable text output --nohtml Disable HTML output --encoding=ENC Set mail text encoding to ENC (default: UTF-8) Options for web links: --cvsurl=URL set URL for ViewCVS viewcvs.cgi (default: none) --bugurl=URL set URL for Bugzilla's show_bug.cgi (default: none) Miscellaneous options: -v, --verbose turn on verbose diagnostic output (specify twice to increase verbosity). -h, --help brief help message --man full documentation =head1 USAGE Add this line to your CVSROOT/loginfo: # For CVS 1.11.x ALL (echo %{sVv}; echo "Author: $USER"; cat ; echo "---END---") >> $CVSROOT/commitlog.new # For CVS 1.12.x ALL perl -e 'print "Author: ".shift()."\nDir: ".shift()."\n"; while(@ARGV) { print "File: ".shift().",".shift().",".shift()."\n" } while() { print }; print "---END---\n"' $USER %p %{sVv} >> $CVSROOT/commitlog.new Put something like this in your crontab: 29 */6 * * * root /usr/local/bin/deluxeloginfo \ --prjtab=/etc/projects/prjtab \ --cvsurl="http://cvs.develer.com/viewcvs.cgi" \ --bugurl="http://bugs.develer.com/show_bug.cgi" If you have multiple CVS repositories, you can process all them at once by setting up a project description file like this: #NAME ROOT RECIPIENTS kde /repos/kde kde-devel@lists.kde.org gnome /cvs/gnome gnome-devel@lists.gnome.org uclinux :ext:bernie@cvs.uclinux.org:/cvsroot uclinux-dev@uclinux.org gcc svn+ssh://gcc.gnu.org/svn/gcc news:comp.lang.c++,gnu.misc.discuss Multiple recipients can be specified separated by commas. Prefix the address list with "news:" to post to newsgroups. =head1 AUTHOR Written by Bernardo Innocenti . Contact me for patch submissions and bug reports. =cut