3 # ====================================================================
4 # commit-email.pl: send a notification email describing either a
5 # commit or a revprop-change action on a Subversion repository.
7 # For usage, see the usage subroutine or run the script with no
8 # command line arguments.
10 # This script requires Subversion 1.2.0 or later.
15 # $LastChangedRevision$
17 # ====================================================================
18 # Copyright (c) 2000-2006 CollabNet. All rights reserved.
20 # This software is licensed as described in the file COPYING, which
21 # you should have received as part of this distribution. The terms
22 # are also available at http://subversion.tigris.org/license-1.html.
23 # If newer versions of this license are posted there, you may use a
24 # newer version instead, at your option.
26 # This software consists of voluntary contributions made by many
27 # individuals. For exact contribution history, see the revision
28 # history and logs, available at http://subversion.tigris.org/.
29 # ====================================================================
31 # Turn on warnings the best way depending on the Perl version.
34 { require warnings
; import warnings
; }
41 use POSIX
qw(strftime);
42 my ($sendmail, $smtp_server);
44 ######################################################################
45 # Configuration section.
47 # Sendmail path, or SMTP server address.
48 # You should define exactly one of these two configuration variables,
49 # leaving the other commented out, to select which method of sending
50 # email should be used.
51 # Using --stdout on the command line overrides both.
52 #$sendmail = "/usr/sbin/sendmail";
53 $smtp_server = "127.0.0.1";
56 my $svnlook = "@SVN_BINDIR@/svnlook";
58 # By default, when a file is deleted from the repository, svnlook diff
59 # prints the entire contents of the file. If you want to save space
60 # in the log and email messages by not printing the file, then set
61 # $no_diff_deleted to 1.
62 my $no_diff_deleted = 0;
63 # By default, when a file is added to the repository, svnlook diff
64 # prints the entire contents of the file. If you want to save space
65 # in the log and email messages by not printing the file, then set
66 # $no_diff_added to 1.
67 my $no_diff_added = 0;
69 # End of Configuration section.
70 ######################################################################
72 # Check that the required programs exist, and the email sending method
73 # configuration is sane, to ensure that the administrator has set up
74 # the script properly.
77 foreach my $program ($sendmail, $svnlook)
79 next if not defined $program;
84 warn "$0: required program `$program' is not executable, ",
91 warn "$0: required program `$program' does not exist, edit $0.\n";
95 if (not (defined $sendmail xor defined $smtp_server))
97 warn "$0: exactly one of \$sendmail or \$smtp_server must be ",
104 require Net
::SMTP
if defined $smtp_server;
106 ######################################################################
107 # Initial setup/command-line handling.
109 # Each value in this array holds a hash reference which contains the
110 # associated email information for one project. Start with an
111 # implicit rule that matches all paths.
112 my @project_settings_list = (&new_project
);
114 # Process the command line arguments till there are none left.
115 # In commit mode: The first two arguments that are not used by a command line
116 # option are the repository path and the revision number.
117 # In revprop-change mode: The first four arguments that are not used by a
118 # command line option are the repository path, the revision number, the
119 # author, and the property name. This script has no support for the fifth
120 # argument (action) added to the post-revprop-change hook in Subversion
121 # 1.2.0 yet - patches welcome!
131 # Use the reference to the first project to populate.
132 my $current_project = $project_settings_list[0];
134 # This hash matches the command line option to the hash key in the
135 # project. If a key exists but has a false value (''), then the
136 # command line option is allowed but requires special handling.
137 my %opt_to_hash_key = ('--from' => 'from_address',
138 '--revprop-change' => '',
144 '-s' => 'subject_prefix',
150 my $arg = shift @ARGV;
153 my $hash_key = $opt_to_hash_key{$arg};
154 unless (defined $hash_key)
156 die "$0: command line option `$arg' is not recognized.\n";
160 if ($arg ne '--revprop-change' and $arg ne '--stdout')
164 die "$0: command line option `$arg' is missing a value.\n";
166 $value = shift @ARGV;
171 $current_project->{$hash_key} = $value;
177 $current_project = &new_project
;
178 $current_project->{match_regex
} = $value;
179 push(@project_settings_list, $current_project);
183 if ($mode ne 'revprop-change')
185 die "$0: `-d' is valid only when used after"
186 . " `--revprop-change'.\n";
190 die "$0: command line option `$arg'"
191 . " can only be used once.\n";
195 elsif ($arg eq '--revprop-change')
199 die "$0: `--revprop-change' must be specified before"
200 . " the first non-option argument.\n";
202 $mode = 'revprop-change';
204 elsif ($arg eq '--diff')
206 $current_project->{show_diff
} = parse_boolean
($value);
208 elsif ($arg eq '--stdout')
210 $current_project->{stdout
} = 1;
214 die "$0: internal error:"
215 . " should not be handling `$arg' here.\n";
221 if (! defined $repos)
225 elsif (! defined $rev)
229 elsif (! defined $author && $mode eq 'revprop-change')
233 elsif (! defined $propname && $mode eq 'revprop-change')
239 push(@
{$current_project->{email_addresses
}}, $arg);
244 if ($mode eq 'commit')
246 &usage
("$0: too few arguments.") unless defined $rev;
248 elsif ($mode eq 'revprop-change')
250 &usage
("$0: too few arguments.") unless defined $propname;
253 # Check the validity of the command line arguments. Check that the
254 # revision is an integer greater than 0 and that the repository
256 unless ($rev =~ /^\d+/ and $rev > 0)
258 &usage
("$0: revision number `$rev' must be an integer > 0.");
262 &usage
("$0: repos directory `$repos' does not exist.");
266 &usage
("$0: repos directory `$repos' is not a directory.");
269 # Check that all of the regular expressions can be compiled and
273 for (my $i=0; $i<@project_settings_list; ++$i)
275 my $match_regex = $project_settings_list[$i]->{match_regex
};
277 # To help users that automatically write regular expressions
278 # that match the root directory using ^/, remove the / character
279 # because subversion paths, while they start at the root level,
280 # do not begin with a /.
281 $match_regex =~ s
#^\^/#^#;
284 eval { $match_re = qr/$match_regex/ };
287 warn "$0: -m regex #$i `$match_regex' does not compile:\n$@\n";
291 $project_settings_list[$i]->{match_re
} = $match_re;
296 # Harvest common data needed for both commit or revprop-change.
298 # Figure out what directories have changed using svnlook.
299 my @dirschanged = &read_from_process
($svnlook, 'dirs-changed', $repos,
302 # Lose the trailing slash in the directory names if one exists, except
303 # in the case of '/'.
305 for (my $i=0; $i<@dirschanged; ++$i)
307 if ($dirschanged[$i] eq '/')
313 $dirschanged[$i] =~ s
#^(.+)[/\\]$#$1#;
317 # Figure out what files have changed using svnlook.
318 my @svnlooklines = &read_from_process
($svnlook, 'changed', $repos, '-r', $rev);
320 # Parse the changed nodes.
324 foreach my $line (@svnlooklines)
329 # Split the line up into the modification code and path, ignoring
330 # property modifications.
331 if ($line =~ /^(.). (.*)$/)
351 # Declare variables which carry information out of the inner scope of
352 # the conditional blocks below.
355 # $author - declared above for use as a command line parameter in
356 # revprop-change mode. In commit mode, gets filled in below.
358 if ($mode eq 'commit')
360 ######################################################################
361 # Harvest data using svnlook.
363 # Get the author, date, and log from svnlook.
364 my @infolines = &read_from_process
($svnlook, 'info', $repos, '-r', $rev);
365 $author = shift @infolines;
366 $date = shift @infolines;
368 my @log = map { "$_\n" } @infolines;
370 ######################################################################
371 # Modified directory name collapsing.
373 # Collapse the list of changed directories only if the root directory
374 # was not modified, because otherwise everything is under root and
375 # there's no point in collapsing the directories, and only if more
376 # than one directory was modified.
378 my @edited_dirschanged = @dirschanged;
379 if (!$rootchanged and @edited_dirschanged > 1)
381 my $firstline = shift @edited_dirschanged;
382 my @commonpieces = split('/', $firstline);
383 foreach my $line (@edited_dirschanged)
385 my @pieces = split('/', $line);
387 while ($i < @pieces and $i < @commonpieces)
389 if ($pieces[$i] ne $commonpieces[$i])
391 splice(@commonpieces, $i, @commonpieces - $i);
397 unshift(@edited_dirschanged, $firstline);
401 $commondir = join('/', @commonpieces);
403 foreach my $dir (@edited_dirschanged)
405 if ($dir eq $commondir)
411 $dir =~ s
#^\Q$commondir/\E##;
413 push(@new_dirschanged, $dir);
415 @edited_dirschanged = @new_dirschanged;
418 my $dirlist = join(' ', @edited_dirschanged);
420 ######################################################################
421 # Assembly of log message.
423 if ($commondir ne '')
425 $subject_base = "r$rev - in $commondir: $dirlist";
429 $subject_base = "r$rev - $dirlist";
432 # Put together the body of the log message.
433 push(@body, "Author: $author\n");
434 push(@body, "Date: $date\n");
435 push(@body, "New Revision: $rev\n");
440 push(@body, "Added:\n");
441 push(@body, map { " $_\n" } @adds);
446 push(@body, "Removed:\n");
447 push(@body, map { " $_\n" } @dels);
452 push(@body, "Modified:\n");
453 push(@body, map { " $_\n" } @mods);
455 push(@body, "Log:\n");
459 elsif ($mode eq 'revprop-change')
461 ######################################################################
465 # Get the diff file if it was provided, otherwise the property value.
468 open(DIFF_FILE
, $diff_file) or die "$0: cannot read `$diff_file': $!\n";
469 @svnlines = <DIFF_FILE
>;
474 @svnlines = &read_from_process
($svnlook, 'propget', '--revprop', '-r',
475 $rev, $repos, $propname);
478 ######################################################################
479 # Assembly of log message.
481 $subject_base = "propchange - r$rev $propname";
483 # Put together the body of the log message.
484 push(@body, "Author: $author\n");
485 push(@body, "Revision: $rev\n");
486 push(@body, "Property Name: $propname\n");
490 push(@body, "New Property Value:\n");
492 push(@body, map { /[\r\n]+$/ ?
$_ : "$_\n" } @svnlines);
496 # Cached information - calculated when first needed.
499 # Go through each project and see if there are any matches for this
500 # project. If so, send the log out.
501 foreach my $project (@project_settings_list)
503 my $match_re = $project->{match_re
};
505 foreach my $path (@dirschanged, @adds, @dels, @mods)
507 if ($path =~ $match_re)
516 my @email_addresses = @
{$project->{email_addresses
}};
517 my $userlist = join(' ', @email_addresses);
518 my $to = join(', ', @email_addresses);
519 my $from_address = $project->{from_address
};
520 my $hostname = $project->{hostname
};
521 my $log_file = $project->{log_file
};
522 my $reply_to = $project->{reply_to
};
523 my $subject_prefix = $project->{subject_prefix
};
524 my $subject = $subject_base;
525 my $diff_wanted = ($project->{show_diff
} and $mode eq 'commit');
526 my $stdout = $project->{stdout
};
528 if ($subject_prefix =~ /\w/)
530 $subject = "$subject_prefix $subject";
532 my $mail_from = $author;
534 if ($from_address =~ /\w/)
536 $mail_from = $from_address;
538 elsif ($hostname =~ /\w/)
540 $mail_from = "$mail_from\@$hostname";
542 elsif (defined $smtp_server and ! $stdout)
544 die "$0: use of either `-h' or `--from' is mandatory when ",
545 "sending email using direct SMTP.\n";
552 $formatted_date = strftime
('%a %b %e %X %Y', localtime());
553 push(@head, "From $mail_from $formatted_date\n");
555 $formatted_date = strftime
('%a, %e %b %Y %X %z', localtime());
556 push(@head, "Date: $formatted_date\n");
557 push(@head, "To: $to\n");
558 push(@head, "From: $mail_from\n");
559 push(@head, "Subject: $subject\n");
560 push(@head, "Reply-to: $reply_to\n") if $reply_to;
562 ### Below, we set the content-type etc, but see these comments
563 ### from Greg Stein on why this is not a full solution.
565 # From: Greg Stein <gstein@lyra.org>
566 # Subject: Re: svn commit: rev 2599 - trunk/tools/cgi
567 # To: dev@subversion.tigris.org
568 # Date: Fri, 19 Jul 2002 23:42:32 -0700
570 # Well... that isn't strictly true. The contents of the files
571 # might not be UTF-8, so the "diff" portion will be hosed.
573 # If you want a truly "proper" commit message, then you'd use
574 # multipart MIME messages, with each file going into its own part,
575 # and labeled with an appropriate MIME type and charset. Of
576 # course, we haven't defined a charset property yet, but no biggy.
578 # Going with multipart will surely throw out the notion of "cut
579 # out the patch from the email and apply." But then again: the
580 # commit emailer could see that all portions are in the same
581 # charset and skip the multipart thang.
585 # Basically: adding/tweaking the content-type is nice, but don't
586 # think that is the proper solution.
587 push(@head, "Content-Type: text/plain; charset=UTF-8\n");
588 push(@head, "Content-Transfer-Encoding: 8bit\n");
592 if ($diff_wanted and not @difflines)
594 # Get the diff from svnlook.
595 my @no_diff_deleted = $no_diff_deleted ?
('--no-diff-deleted') : ();
596 my @no_diff_added = $no_diff_added ?
('--no-diff-added') : ();
597 @difflines = &read_from_process
($svnlook, 'diff', $repos,
598 '-r', $rev, @no_diff_deleted,
600 @difflines = map { /[\r\n]+$/ ?
$_ : "$_\n" } @difflines;
606 print @difflines if $diff_wanted;
608 elsif (defined $sendmail and @email_addresses)
610 # Open a pipe to sendmail.
611 my $command = "$sendmail -f'$mail_from' $userlist";
612 if (open(SENDMAIL
, "| $command"))
614 print SENDMAIL
@head, @body;
615 print SENDMAIL
@difflines if $diff_wanted;
617 or warn "$0: error in closing `$command' for writing: $!\n";
621 warn "$0: cannot open `| $command' for writing: $!\n";
624 elsif (defined $smtp_server and @email_addresses)
626 my $smtp = Net
::SMTP
->new($smtp_server)
627 or die "$0: error opening SMTP session to `$smtp_server': $!\n";
628 handle_smtp_error
($smtp, $smtp->mail($mail_from));
629 handle_smtp_error
($smtp, $smtp->recipient(@email_addresses));
630 handle_smtp_error
($smtp, $smtp->data());
631 handle_smtp_error
($smtp, $smtp->datasend(@head, @body));
634 handle_smtp_error
($smtp, $smtp->datasend(@difflines));
636 handle_smtp_error
($smtp, $smtp->dataend());
637 handle_smtp_error
($smtp, $smtp->quit());
640 # Dump the output to logfile (if its name is not empty).
641 if ($log_file =~ /\w/)
643 if (open(LOGFILE
, ">> $log_file"))
645 print LOGFILE
@head, @body;
646 print LOGFILE
@difflines if $diff_wanted;
648 or warn "$0: error in closing `$log_file' for appending: $!\n";
652 warn "$0: cannot open `$log_file' for appending: $!\n";
659 sub handle_smtp_error
661 my ($smtp, $retval) = @_;
664 die "$0: SMTP Error: " . $smtp->message() . "\n";
671 die "usage (commit mode):\n",
672 " $0 REPOS REVNUM [[-m regex] [options] [email_addr ...]] ...\n",
673 "usage: (revprop-change mode):\n",
674 " $0 --revprop-change REPOS REVNUM USER PROPNAME [-d diff_file] \\\n",
675 " [[-m regex] [options] [email_addr ...]] ...\n",
677 " --from email_address Email address for 'From:' (overrides -h)\n",
678 " -h hostname Hostname to append to author for 'From:'\n",
679 " -l logfile Append mail contents to this log file\n",
680 " -m regex Regular expression to match committed path\n",
681 " -r email_address Email address for 'Reply-To:'\n",
682 " -s subject_prefix Subject line prefix\n",
683 " --diff y|n Include diff in message (default: y)\n",
684 " (applies to commit mode only)\n",
685 " --stdout Spit the message in mbox format to stdout.\n",
687 "This script supports a single repository with multiple projects,\n",
688 "where each project receives email only for actions that affect that\n",
689 "project. A project is identified by using the -m command line\n".
690 "option with a regular expression argument. If the given revision\n",
691 "contains modifications to a path that matches the regular\n",
692 "expression, then the action applies to the project.\n",
694 "Any of the following -h, -l, -r, -s and --diff command line options\n",
695 "and following email addresses are associated with this project. The\n",
696 "next -m resets the -h, -l, -r, -s and --diff command line options\n",
697 "and the list of email addresses.\n",
699 "To support a single project conveniently, the script initializes\n",
700 "itself with an implicit -m . rule that matches any modifications\n",
701 "to the repository. Therefore, to use the script for a single-\n",
702 "project repository, just use the other command line options and\n",
703 "a list of email addresses on the command line. If you do not want\n",
704 "a rule that matches the entire repository, then use -m with a\n",
705 "regular expression before any other command line options or email\n",
708 "'revprop-change' mode:\n",
709 "The message will contain a copy of the diff_file if it is provided,\n",
710 "otherwise a copy of the (assumed to be new) property value.\n",
714 # Return a new hash data structure for a new empty project that
715 # matches any modifications to the repository.
718 return {email_addresses
=> [],
724 subject_prefix
=> '',
731 if ($_[0] eq 'y') { return 1; };
732 if ($_[0] eq 'n') { return 0; };
734 die "$0: valid boolean options are 'y' or 'n', not '$_[0]'\n";
737 # Start a child process safely without using /bin/sh.
738 sub safe_read_from_pipe
742 croak
"$0: safe_read_from_pipe passed no arguments.\n";
745 my $openfork_available = $^O
ne "MSWin32";
746 if ($openfork_available) # We can fork on this system.
748 my $pid = open(SAFE_READ
, '-|');
749 unless (defined $pid)
751 die "$0: cannot fork: $!\n";
755 open(STDERR
, ">&STDOUT")
756 or die "$0: cannot dup STDOUT: $!\n";
758 or die "$0: cannot exec `@_': $!\n";
761 else # Running on Windows. No fork.
763 my @commandline = ();
769 if ($arg eq "" or $arg =~ /\s/) { $arg = "\"$arg\""; }
770 push(@commandline, $arg);
774 open(SAFE_READ
, "@commandline |")
775 or die "$0: cannot pipe to command: $!\n";
785 my $exit = $result >> 8;
786 my $signal = $result & 127;
787 my $cd = $result & 128 ?
"with core dump" : "";
790 warn "$0: pipe from `@_' failed $cd: exit=$exit signal=$signal\n";
794 return ($result, @output);
802 # Use safe_read_from_pipe to start a child process safely and return
803 # the output if it succeeded or an error message followed by the output
805 sub read_from_process
809 croak
"$0: read_from_process passed no arguments.\n";
811 my ($status, @output) = &safe_read_from_pipe
(@_);
814 return ("$0: `@_' failed with this output:", @output);