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',
151 my $arg = shift @ARGV;
154 my $hash_key = $opt_to_hash_key{$arg};
155 unless (defined $hash_key)
157 die "$0: command line option `$arg' is not recognized.\n";
161 if ($arg ne '--revprop-change' and $arg ne '--stdout' and $arg ne '--summary')
165 die "$0: command line option `$arg' is missing a value.\n";
167 $value = shift @ARGV;
172 $current_project->{$hash_key} = $value;
178 $current_project = &new_project
;
179 $current_project->{match_regex
} = $value;
180 push(@project_settings_list, $current_project);
184 if ($mode ne 'revprop-change')
186 die "$0: `-d' is valid only when used after"
187 . " `--revprop-change'.\n";
191 die "$0: command line option `$arg'"
192 . " can only be used once.\n";
196 elsif ($arg eq '--revprop-change')
200 die "$0: `--revprop-change' must be specified before"
201 . " the first non-option argument.\n";
203 $mode = 'revprop-change';
205 elsif ($arg eq '--diff')
207 $current_project->{show_diff
} = parse_boolean
($value);
209 elsif ($arg eq '--stdout')
211 $current_project->{stdout
} = 1;
213 elsif ($arg eq '--summary')
215 $current_project->{summary
} = 1;
219 die "$0: internal error:"
220 . " should not be handling `$arg' here.\n";
226 if (! defined $repos)
230 elsif (! defined $rev)
234 elsif (! defined $author && $mode eq 'revprop-change')
238 elsif (! defined $propname && $mode eq 'revprop-change')
244 push(@
{$current_project->{email_addresses
}}, $arg);
249 if ($mode eq 'commit')
251 &usage
("$0: too few arguments.") unless defined $rev;
253 elsif ($mode eq 'revprop-change')
255 &usage
("$0: too few arguments.") unless defined $propname;
258 # Check the validity of the command line arguments. Check that the
259 # revision is an integer greater than 0 and that the repository
261 unless ($rev =~ /^\d+/ and $rev > 0)
263 &usage
("$0: revision number `$rev' must be an integer > 0.");
267 &usage
("$0: repos directory `$repos' does not exist.");
271 &usage
("$0: repos directory `$repos' is not a directory.");
274 # Check that all of the regular expressions can be compiled and
278 for (my $i=0; $i<@project_settings_list; ++$i)
280 my $match_regex = $project_settings_list[$i]->{match_regex
};
282 # To help users that automatically write regular expressions
283 # that match the root directory using ^/, remove the / character
284 # because subversion paths, while they start at the root level,
285 # do not begin with a /.
286 $match_regex =~ s
#^\^/#^#;
289 eval { $match_re = qr/$match_regex/ };
292 warn "$0: -m regex #$i `$match_regex' does not compile:\n$@\n";
296 $project_settings_list[$i]->{match_re
} = $match_re;
301 # Harvest common data needed for both commit or revprop-change.
303 # Figure out what directories have changed using svnlook.
304 my @dirschanged = &read_from_process
($svnlook, 'dirs-changed', $repos,
307 # Lose the trailing slash in the directory names if one exists, except
308 # in the case of '/'.
310 for (my $i=0; $i<@dirschanged; ++$i)
312 if ($dirschanged[$i] eq '/')
318 $dirschanged[$i] =~ s
#^(.+)[/\\]$#$1#;
322 # Figure out what files have changed using svnlook.
323 my @svnlooklines = &read_from_process
($svnlook, 'changed', $repos, '-r', $rev);
325 # Parse the changed nodes.
329 foreach my $line (@svnlooklines)
334 # Split the line up into the modification code and path, ignoring
335 # property modifications.
336 if ($line =~ /^(.). (.*)$/)
356 # Declare variables which carry information out of the inner scope of
357 # the conditional blocks below.
361 # $author - declared above for use as a command line parameter in
362 # revprop-change mode. In commit mode, gets filled in below.
364 if ($mode eq 'commit')
366 ######################################################################
367 # Harvest data using svnlook.
369 # Get the author, date, and log from svnlook.
370 my @infolines = &read_from_process
($svnlook, 'info', $repos, '-r', $rev);
371 $author = shift @infolines;
372 $date = shift @infolines;
374 my @log = map { "$_\n" } @infolines;
376 ######################################################################
377 # Modified directory name collapsing.
379 # Collapse the list of changed directories only if the root directory
380 # was not modified, because otherwise everything is under root and
381 # there's no point in collapsing the directories, and only if more
382 # than one directory was modified.
384 my @edited_dirschanged = @dirschanged;
385 if (!$rootchanged and @edited_dirschanged > 1)
387 my $firstline = shift @edited_dirschanged;
388 my @commonpieces = split('/', $firstline);
389 foreach my $line (@edited_dirschanged)
391 my @pieces = split('/', $line);
393 while ($i < @pieces and $i < @commonpieces)
395 if ($pieces[$i] ne $commonpieces[$i])
397 splice(@commonpieces, $i, @commonpieces - $i);
403 unshift(@edited_dirschanged, $firstline);
407 $commondir = join('/', @commonpieces);
409 foreach my $dir (@edited_dirschanged)
411 if ($dir eq $commondir)
417 $dir =~ s
#^\Q$commondir/\E##;
419 push(@new_dirschanged, $dir);
421 @edited_dirschanged = @new_dirschanged;
424 my $dirlist = join(' ', @edited_dirschanged);
426 ######################################################################
427 # Assembly of log message.
429 if ($commondir ne '')
431 $subject_base = "r$rev - in $commondir: $dirlist";
435 $subject_base = "r$rev - $dirlist";
437 my $summary = @log ?
$log[0] : '';
439 $subject_logbase = "r$rev - $summary";
441 # Put together the body of the log message.
442 push(@body, "Author: $author\n");
443 push(@body, "Date: $date\n");
444 push(@body, "New Revision: $rev\n");
449 push(@body, "Added:\n");
450 push(@body, map { " $_\n" } @adds);
455 push(@body, "Removed:\n");
456 push(@body, map { " $_\n" } @dels);
461 push(@body, "Modified:\n");
462 push(@body, map { " $_\n" } @mods);
464 push(@body, "Log:\n");
468 elsif ($mode eq 'revprop-change')
470 ######################################################################
474 # Get the diff file if it was provided, otherwise the property value.
477 open(DIFF_FILE
, $diff_file) or die "$0: cannot read `$diff_file': $!\n";
478 @svnlines = <DIFF_FILE
>;
483 @svnlines = &read_from_process
($svnlook, 'propget', '--revprop', '-r',
484 $rev, $repos, $propname);
487 ######################################################################
488 # Assembly of log message.
490 $subject_base = "propchange - r$rev $propname";
492 # Put together the body of the log message.
493 push(@body, "Author: $author\n");
494 push(@body, "Revision: $rev\n");
495 push(@body, "Property Name: $propname\n");
499 push(@body, "New Property Value:\n");
501 push(@body, map { /[\r\n]+$/ ?
$_ : "$_\n" } @svnlines);
505 # Cached information - calculated when first needed.
508 # Go through each project and see if there are any matches for this
509 # project. If so, send the log out.
510 foreach my $project (@project_settings_list)
512 my $match_re = $project->{match_re
};
514 foreach my $path (@dirschanged, @adds, @dels, @mods)
516 if ($path =~ $match_re)
525 my @email_addresses = @
{$project->{email_addresses
}};
526 my $userlist = join(' ', @email_addresses);
527 my $to = join(', ', @email_addresses);
528 my $from_address = $project->{from_address
};
529 my $hostname = $project->{hostname
};
530 my $log_file = $project->{log_file
};
531 my $reply_to = $project->{reply_to
};
532 my $subject_prefix = $project->{subject_prefix
};
533 my $summary = $project->{summary
};
534 my $diff_wanted = ($project->{show_diff
} and $mode eq 'commit');
535 my $stdout = $project->{stdout
};
537 my $subject = $summary ?
$subject_logbase : $subject_base;
538 if ($subject_prefix =~ /\w/)
540 $subject = "$subject_prefix $subject";
542 my $mail_from = $author;
544 if ($from_address =~ /\w/)
546 $mail_from = $from_address;
548 elsif ($hostname =~ /\w/)
550 $mail_from = "$mail_from\@$hostname";
552 elsif (defined $smtp_server and ! $stdout)
554 die "$0: use of either `-h' or `--from' is mandatory when ",
555 "sending email using direct SMTP.\n";
562 $formatted_date = strftime
('%a %b %e %X %Y', localtime());
563 push(@head, "From $mail_from $formatted_date\n");
565 $formatted_date = strftime
('%a, %e %b %Y %X %z', localtime());
566 push(@head, "Date: $formatted_date\n");
567 push(@head, "To: $to\n");
568 push(@head, "From: $mail_from\n");
569 push(@head, "Subject: $subject\n");
570 push(@head, "Reply-to: $reply_to\n") if $reply_to;
572 ### Below, we set the content-type etc, but see these comments
573 ### from Greg Stein on why this is not a full solution.
575 # From: Greg Stein <gstein@lyra.org>
576 # Subject: Re: svn commit: rev 2599 - trunk/tools/cgi
577 # To: dev@subversion.tigris.org
578 # Date: Fri, 19 Jul 2002 23:42:32 -0700
580 # Well... that isn't strictly true. The contents of the files
581 # might not be UTF-8, so the "diff" portion will be hosed.
583 # If you want a truly "proper" commit message, then you'd use
584 # multipart MIME messages, with each file going into its own part,
585 # and labeled with an appropriate MIME type and charset. Of
586 # course, we haven't defined a charset property yet, but no biggy.
588 # Going with multipart will surely throw out the notion of "cut
589 # out the patch from the email and apply." But then again: the
590 # commit emailer could see that all portions are in the same
591 # charset and skip the multipart thang.
595 # Basically: adding/tweaking the content-type is nice, but don't
596 # think that is the proper solution.
597 push(@head, "Content-Type: text/plain; charset=UTF-8\n");
598 push(@head, "Content-Transfer-Encoding: 8bit\n");
602 if ($diff_wanted and not @difflines)
604 # Get the diff from svnlook.
605 my @no_diff_deleted = $no_diff_deleted ?
('--no-diff-deleted') : ();
606 my @no_diff_added = $no_diff_added ?
('--no-diff-added') : ();
607 @difflines = &read_from_process
($svnlook, 'diff', $repos,
608 '-r', $rev, @no_diff_deleted,
610 @difflines = map { /[\r\n]+$/ ?
$_ : "$_\n" } @difflines;
616 print @difflines if $diff_wanted;
618 elsif (defined $sendmail and @email_addresses)
620 # Open a pipe to sendmail.
621 my $command = "$sendmail -f'$mail_from' $userlist";
622 if (open(SENDMAIL
, "| $command"))
624 print SENDMAIL
@head, @body;
625 print SENDMAIL
@difflines if $diff_wanted;
627 or warn "$0: error in closing `$command' for writing: $!\n";
631 warn "$0: cannot open `| $command' for writing: $!\n";
634 elsif (defined $smtp_server and @email_addresses)
636 my $smtp = Net
::SMTP
->new($smtp_server)
637 or die "$0: error opening SMTP session to `$smtp_server': $!\n";
638 handle_smtp_error
($smtp, $smtp->mail($mail_from));
639 handle_smtp_error
($smtp, $smtp->recipient(@email_addresses));
640 handle_smtp_error
($smtp, $smtp->data());
641 handle_smtp_error
($smtp, $smtp->datasend(@head, @body));
644 handle_smtp_error
($smtp, $smtp->datasend(@difflines));
646 handle_smtp_error
($smtp, $smtp->dataend());
647 handle_smtp_error
($smtp, $smtp->quit());
650 # Dump the output to logfile (if its name is not empty).
651 if ($log_file =~ /\w/)
653 if (open(LOGFILE
, ">> $log_file"))
655 print LOGFILE
@head, @body;
656 print LOGFILE
@difflines if $diff_wanted;
658 or warn "$0: error in closing `$log_file' for appending: $!\n";
662 warn "$0: cannot open `$log_file' for appending: $!\n";
669 sub handle_smtp_error
671 my ($smtp, $retval) = @_;
674 die "$0: SMTP Error: " . $smtp->message() . "\n";
681 die "usage (commit mode):\n",
682 " $0 REPOS REVNUM [[-m regex] [options] [email_addr ...]] ...\n",
683 "usage: (revprop-change mode):\n",
684 " $0 --revprop-change REPOS REVNUM USER PROPNAME [-d diff_file] \\\n",
685 " [[-m regex] [options] [email_addr ...]] ...\n",
687 " -m regex Regular expression to match committed path\n",
688 " --from email_address Email address for 'From:' (overrides -h)\n",
689 " -h hostname Hostname to append to author for 'From:'\n",
690 " -l logfile Append mail contents to this log file\n",
691 " -r email_address Email address for 'Reply-To:'\n",
692 " -s subject_prefix Subject line prefix\n",
693 " --summary Use first line of commit log in subject\n",
694 " --diff y|n Include diff in message (default: y)\n",
695 " (applies to commit mode only)\n",
696 " --stdout Spit the message in mbox format to stdout.\n",
698 "This script supports a single repository with multiple projects,\n",
699 "where each project receives email only for actions that affect that\n",
700 "project. A project is identified by using the -m command line\n".
701 "option with a regular expression argument. If the given revision\n",
702 "contains modifications to a path that matches the regular\n",
703 "expression, then the action applies to the project.\n",
705 "Any of the following email addresses and command line options\n",
706 "(other than -d) are associated with this project, until the next -m,\n",
707 "which resets the options and the list of email addresses.\n",
709 "To support a single project conveniently, the script initializes\n",
710 "itself with an implicit -m . rule that matches any modifications\n",
711 "to the repository. Therefore, to use the script for a single-\n",
712 "project repository, just use the other command line options and\n",
713 "a list of email addresses on the command line. If you do not want\n",
714 "a rule that matches the entire repository, then use -m with a\n",
715 "regular expression before any other command line options or email\n",
718 "'revprop-change' mode:\n",
719 "The message will contain a copy of the diff_file if it is provided,\n",
720 "otherwise a copy of the (assumed to be new) property value.\n",
724 # Return a new hash data structure for a new empty project that
725 # matches any modifications to the repository.
728 return {email_addresses
=> [],
734 subject_prefix
=> '',
741 if ($_[0] eq 'y') { return 1; };
742 if ($_[0] eq 'n') { return 0; };
744 die "$0: valid boolean options are 'y' or 'n', not '$_[0]'\n";
747 # Start a child process safely without using /bin/sh.
748 sub safe_read_from_pipe
752 croak
"$0: safe_read_from_pipe passed no arguments.\n";
755 my $openfork_available = $^O
ne "MSWin32";
756 if ($openfork_available) # We can fork on this system.
758 my $pid = open(SAFE_READ
, '-|');
759 unless (defined $pid)
761 die "$0: cannot fork: $!\n";
765 open(STDERR
, ">&STDOUT")
766 or die "$0: cannot dup STDOUT: $!\n";
768 or die "$0: cannot exec `@_': $!\n";
771 else # Running on Windows. No fork.
773 my @commandline = ();
779 if ($arg eq "" or $arg =~ /\s/) { $arg = "\"$arg\""; }
780 push(@commandline, $arg);
784 open(SAFE_READ
, "@commandline |")
785 or die "$0: cannot pipe to command: $!\n";
795 my $exit = $result >> 8;
796 my $signal = $result & 127;
797 my $cd = $result & 128 ?
"with core dump" : "";
800 warn "$0: pipe from `@_' failed $cd: exit=$exit signal=$signal\n";
804 return ($result, @output);
812 # Use safe_read_from_pipe to start a child process safely and return
813 # the output if it succeeded or an error message followed by the output
815 sub read_from_process
819 croak
"$0: read_from_process passed no arguments.\n";
821 my ($status, @output) = &safe_read_from_pipe
(@_);
824 return ("$0: `@_' failed with this output:", @output);