#!/usr/bin/perl -w # # The contents of this file are subject to the Mozilla Public # License Version 1.1 (the "License"); you may not use this file # except in compliance with the License. You may obtain a copy of # the License at http://www.mozilla.org/MPL/ # # Software distributed under the License is distributed on an "AS # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or # implied. See the License for the specific language governing # rights and limitations under the License. # # The Original Code is the Mozilla IRC Bot # # The Initial Developer of the Original Code is Max Kanat-Alexander. # Portions developed by Max Kanat-Alexander are Copyright (C) 2005 # Max Kanat-Alexander. All Rights Reserved. # # Contributor(s): Max Kanat-Alexander # # This is loosely based off an older bugmail.pl by justdave. # bugmail.pl requires that you have X-Bugzilla-Product and # X-Bugzilla-Component headers in your incoming email. In 2.19.2 and above, # this is easy. You just add two lines to your newchangedmail param: # X-Bugzilla-Product: %product% # X-Bugzilla-Component: %component% # If you're running 2.18, you can do the same thing, but you need to # apply the patch from bug 175222 # to your installation. use strict; use Fcntl qw(:flock); use File::Basename; use Email::Simple; ##################################################################### # Constants And Initial Setup ##################################################################### # What separates Product//Component//[Fields], etc. in a log line. use constant FIELD_SEPARATOR => '::::'; # These are fields that are multi-select fields, so when somebody # adds something to them, the verbs "added to " or "removed from" should # be used instead of the verb "changed" or "set". # It's a hash, where the names of the fields are the keys, and the values are 1. # The fields are named as they appear in the "What" part of a bugmail "changes" # table. use constant MULTI_FIELDS => { 'CC' => 1, 'Group' => 1, 'Keywords' => 1, 'BugsThisDependsOn' => 1, 'OtherBugsDependingOnThis' => 1, }; # Some fields have such long names for the "What" column that their names # wrap. Normally, our code would think that those fields were two different # fields. So, instead, we store a list of strings to use as an argument # to "grep" for the field names that we need to "unwrap." use constant UNWRAP_WHAT => ( qr/^Attachment .\d+$/, qr/^Attachment .\d+ is$/, qr/^OtherBugsDep/, ); # Should be whatever Bugzilla::Util::find_wrap_point (or FindWrapPoint) # breaks on, in Bugzilla. use constant BREAKING_CHARACTERS => (' ',',','-'); # The maximum width, in characters, of each field of the "diffs" table. use constant WIDTH_WHAT => 19; use constant WIDTH_REMOVED => 28; use constant WIDTH_ADDED => 28; # Our one command-line argument. our $debug = $ARGV[0] && $ARGV[0] eq "-d"; # XXX - This probably should happen in the log directory instead, but that's # more difficult to figure out reliably. my $bug_log = dirname($0) . '/.bugmail.log'; ##################################################################### # Utility Functions ##################################################################### # When processing the "diffs" table in a bug, some lines wrap. This # function properly appends the "next" line for unwrapping to an # existing string. sub append_diffline ($$$$) { my ($append_to, $prev_line, $append_line, $max_width) = @_; my $ret_line = $append_to; debug_print("Appending Line: [$append_line] Prev Line: [$prev_line]"); debug_print("Prev Line Len: " . length($prev_line) . " Max Width: $max_width"); # If the previous line is the width of the entire column, we # assume that we were forcibly wrapped in the middle of a word, # and no space is needed. We only add the space if we were actually # given a non-empty string to append. if ($append_line && length($prev_line) != $max_width) { debug_print("Adding a space unless we find a breaking character."); # However, sometimes even if we have a very short line, if it ended # in a "breaking character" like '-' then we also don't need a space. $ret_line .= " " unless grep($prev_line =~ /$_$/, BREAKING_CHARACTERS); } $ret_line .= $append_line; debug_print("Appended Line: [$ret_line]"); return $ret_line; } # Prints a string if debugging is on. Appends a newline so you don't have to. sub debug_print ($) { (print STDERR $_[0] . "\n") if $debug; } # Helps with generate_log for Flag messages. sub flag_action ($$) { my ($new, $old) = @_; my $line = ""; my ($flag_name, $action, $requestee) = split_flag($new); debug_print("Parsing Flag Change: Name: [$flag_name] Action: [$action]") if $new; if (!$new) { $line .= " cancelled $old"; } elsif ($action eq '+') { $line .= " granted $flag_name"; } elsif ($action eq '-') { $line .= " denied $flag_name"; } else { $line .= " requested $flag_name from"; if ($requestee) { $line .= " " . $requestee; } else { $line .= " the wind"; } } return $line; } # Takes the $old and $new from a Flag field and returns a hash, # where the key is the name of the field, and the value is an # array, where the first item is the old flag string, and the # new flag string is the second item. sub parse_flags ($$) { my ($new, $old) = @_; my %flags; foreach my $old_item (split /\s*,\s*/, $old) { my ($flag_name) = split_flag($old_item); $flags{$flag_name} = [$old_item, '']; } foreach my $new_item (split /\s*,\s*/, $new) { my ($flag_name) = split_flag($new_item); if (!exists $flags{$flag_name}) { $flags{$flag_name} = ['', $new_item]; } else { $flags{$flag_name}[1] = $new_item; } } return %flags; } # Returns a list: the name of the flag, the action (+/-/?), and # the requestee (if that exists). sub split_flag ($) { my ($flag) = @_; if ($flag) { $flag =~ /\s*([^\?]+)(\+|-|\?)(?:\((.*)\))?$/; return ($1, $2, $3); } return (); } # Cuts the whitespace off the ends of a string. # Lovingly borrowed from Bugzilla::Util. sub trim ($) { my ($str) = @_; if ($str) { $str =~ s/^\s+//g; $str =~ s/\s+$//g; } return $str; } ##################################################################### # Main Subroutines ##################################################################### # Returns a hash, where the keys are the names of fields. The values # are lists, where the first item is what was removed and the second # item is what was added. sub parse_diffs ($) { my ($body_lines) = @_; my @body = @$body_lines; my %changes = (); # Read in the What | Removed | Added table. # End|of|table will never get run my @diff_table = grep (/^.*\|.*\|.*$/, @body); # The first line is the "What|Removed|Added" line, so goes away. shift(@diff_table); my ($prev_what, $prev_added, $prev_removed); # We can't use foreach because we need to modify @diff_table. while (defined (my $line = shift @diff_table)) { $line =~ /^(.*)\|(.*)\|(.*)$/; my ($what, $removed, $added) = (trim($1), trim($2), trim($3)); # These are used to set $prev_removed and $prev_added later. my ($this_removed, $this_added) = ($removed, $added); debug_print("---RawLine: $what|$removed|$added\n"); # If we have a field name in the What field. if ($what) { # If this is a two-line "What" field... if( grep($what =~ $_, UNWRAP_WHAT) ) { # Then we need to grab the next line right now. my $next_line = shift @diff_table; debug_print("Next Line: $next_line"); $next_line =~ /^(.*)\|(.*)\|(.*)$/; my ($next_what, $next_removed, $next_added) = (trim($1), trim($2), trim($3)); debug_print("Two-line What: [$what][$next_what]"); $what = append_diffline($what, $what, $next_what, WIDTH_WHAT); if ($next_added) { debug_print("Two-line Added: [$added][$next_added]"); $added = append_diffline($added, $added, $next_added, WIDTH_ADDED); } if ($next_removed) { debug_print("Two-line Removed: [$removed][$next_removed]"); $removed = append_diffline($removed, $removed, $next_removed, WIDTH_REMOVED); } } $changes{$what} = [$removed, $added]; debug_print("Filed as $what: $removed => $added"); # We only set $prev_what if we actually had a $what to put in it. $prev_what = $what; } # Otherwise we're getting data from a previous What. else { my $new_removed = append_diffline($changes{$prev_what}[0], $prev_removed, $removed, WIDTH_REMOVED); my $new_added = append_diffline($changes{$prev_what}[1], $prev_added, $added, WIDTH_ADDED); $changes{$prev_what} = [$new_removed, $new_added]; debug_print("Filed as $prev_what: $removed => $added"); } ($prev_removed, $prev_added) = ($this_removed, $this_added); } return %changes; } # Takes a reference to an array of lines and returns a hashref # containing data for a buglog entry. # Returns undef if the bug should not be entered into the log. sub parse_mail ($) { my ($mail_lines) = @_; my $mail_text = join('', @$mail_lines); my $email = Email::Simple->new($mail_text); debug_print("Parsing Message " . $email->header('Message-ID')); my $body = $email->body; my @body_lines = split("\n", $body); my %bug_info; # Bug ID my $subject = $email->header('Subject'); if ($subject !~ /^\s*\[Bug (\d+)\] /i) { debug_print("Not bug: $subject"); return undef; } $bug_info{'bug_id'} = $1; debug_print("Bug $bug_info{bug_id} found."); # Ignore Dependency mails # XXX - This should probably be an option in the mozbot instead if (my ($dep_line) = grep /bug (\d+), which changed state\.\s*$/, @body_lines) { debug_print("Dependency change ignored: $dep_line."); return undef; } # Product $bug_info{'product'} = $email->header('X-Bugzilla-Product'); unless ($bug_info{'product'}) { debug_print("X-Bugzilla-Product header not found."); return undef; } debug_print("Product '$bug_info{product}' found."); # Component $bug_info{'component'} = $email->header('X-Bugzilla-Component'); unless ($bug_info{'component'}) { debug_print("X-Bugzilla-Component header not found."); return undef; } debug_print("Component '$bug_info{component}' found."); # New or Changed, and getting who did it. if ($subject =~ /^\s*\[Bug \d+\]\s*New: /i) { $bug_info{'new'} = 1; debug_print("Bug is New."); my ($reporter) = grep /^\s+ReportedBy:\s/, @body_lines; $reporter =~ s/^\s+ReportedBy:\s//; $bug_info{'who'} = $reporter; } elsif ( my ($changer_line) = grep /^\S+\schanged:$/, @body_lines) { $changer_line =~ /^(\S+)\s/; $bug_info{'who'} = $1; } elsif ( my ($comment_line) = grep /^-+.*Comment.*From /i, @body_lines ) { $comment_line =~ /^-+.*Comment.*From (\S+) /i; $bug_info{'who'} = $1; } else { debug_print("Could not determine who made the change."); return undef; } debug_print("Who = $bug_info{who}"); # Attachment my $attachid; if (($attachid) = grep /^Created an attachment \(id=\d+\)/, @body_lines) { $attachid =~ /^Created an attachment \(id=(\d+)\)/; $bug_info{'attach_id'} = $1; debug_print("attach_id: $bug_info{attach_id}"); } # Duplicate my $dupid; if (($dupid) = grep /marked as a duplicate of \d+/, @body_lines) { $dupid =~ /marked as a duplicate of (\d+)/; $bug_info{'dup_of'} = $1; debug_print("Got dup_of: $bug_info{dup_of}"); } # Figure out where the diff table ends, and where comments start. my $comments_start_at = 0; foreach my $check_line (@body_lines) { last if $check_line =~ /^-* Additional Comments From /; $comments_start_at++; } debug_print("Comments start at line $comments_start_at."); my @diff_lines = @body_lines[0 .. ($comments_start_at - 1)]; my %diffs = parse_diffs(\@diff_lines); $bug_info{'diffs'} = \%diffs; return \%bug_info; } # Takes the %bug_info hash returned from parse_mail and # makes it into one or more lines for the bugmail log. # BugMail Log Lines have the following format: # ID::::Product::::Component::::Who::::FieldName::::OldValue::::NewValue::::message # OldValue and NewValue can be empty. # FieldName will be 'NewBug' for new bugs, and 'NewAttach' for new attachments. # Each line ends with a newline, except the last one. sub generate_log ($) { my ($bug_info) = @_; my $prefix = $bug_info->{'bug_id'} . FIELD_SEPARATOR . $bug_info->{'product'} . FIELD_SEPARATOR . $bug_info->{'component'} . FIELD_SEPARATOR . $bug_info->{'who'} . FIELD_SEPARATOR; my @lines; # New bugs are easy to handle, so let's handle them first. if ($bug_info->{'new'}) { push(@lines, $prefix . 'NewBug' . FIELD_SEPARATOR # Old and New are empty. . FIELD_SEPARATOR . FIELD_SEPARATOR . "New $bug_info->{product} bug $bug_info->{bug_id}" . " filed by $bug_info->{who}."); } if ($bug_info->{'attach_id'}) { push(@lines, $prefix . 'NewAttach' . FIELD_SEPARATOR # Old and New are empty. . FIELD_SEPARATOR . FIELD_SEPARATOR . "$bug_info->{'who'} added attachment $bug_info->{'attach_id'}" . " to bug $bug_info->{'bug_id'}."); } # And now we handle changes by going over all the diffs, one by one. my %diffs = %{$bug_info->{'diffs'}}; foreach my $field (keys %diffs) { my $old = $diffs{$field}[0]; my $new = $diffs{$field}[1]; # For attachments, we don't want to include the bug number in # the output. $field =~ s/^(Attachment)( .)(\d+)/$1/; my $attach_id = $3; # Flags get a *very* special handling. if ($field =~ /Flag$/) { my %flags = parse_flags($new, $old); foreach my $flag (keys %flags) { my ($old_flag, $new_flag) = @{$flags{$flag}}; my $line = $prefix . $field . FIELD_SEPARATOR . $old_flag . FIELD_SEPARATOR . $new_flag . FIELD_SEPARATOR . $bug_info->{'who'}; $line .= flag_action($new_flag, $old_flag); if ($field =~ /^Attachment/) { $line .= " for attachment $attach_id"; } $line .= " on bug $bug_info->{bug_id}."; push(@lines, $line); } } # All other, non-Flag fields. else { my $line = $prefix . $field . FIELD_SEPARATOR . $old . FIELD_SEPARATOR . $new . FIELD_SEPARATOR . $bug_info->{who}; # Some fields require the verbs "added" and "removed", like the # CC field. if (MULTI_FIELDS->{$field}) { ($line .= " added $new to") if $new; ($line .= " and") if $new && $old; ($line .= " removed $old from") if $old; $line .= " the $field field on bug $bug_info->{bug_id}."; } # If we didn't remove anything, only added something. elsif (!$old) { $line .= " set the $field field on bug" . " $bug_info->{bug_id} to $new"; # Hack for "RESOLVED DUPLICATE" messages. $line .= ' of bug ' . $bug_info->{dup_of} if exists $bug_info->{dup_of}; $line .= '.'; } # If we didn't add anything, only removed something. elsif (!$new) { $line .= " cleared the $field '$old' from bug" . " $bug_info->{bug_id}."; } # If we changed a field from one value to another. else { $line .= " changed the $field on bug" . " $bug_info->{bug_id} from $old to $new."; } push(@lines, $line); } } debug_print("Generated Log Lines."); debug_print("Log Line: $_") foreach (@lines); return join("\n", @lines); } # Takes a string and appends it to the buglog. sub append_log ($) { my ($string) = @_; (open FILE, ">>" . $bug_log) or die "Couldn't open bug log file $bug_log: $!"; debug_print("Waiting for a lock on the log..."); flock(FILE, LOCK_EX); print FILE $string . "\n"; flock(FILE, LOCK_UN); debug_print("Printed lines to log and unlocked file."); close FILE; } ##################################################################### # Main Script ##################################################################### debug_print("\n\n"); unless (-e $bug_log) { print STDERR "$bug_log does not exist, so I assume that mozbot is not" . " running. Discarding incoming message.\n"; exit; } my @mail_array = ; my $bug_info = parse_mail(\@mail_array); if (defined $bug_info) { my $log_lines = generate_log($bug_info); # If we got an email with just a comment, $log_lines will be empty. append_log($log_lines) if $log_lines; } debug_print("All done!"); exit;