]> git.donarmstrong.com Git - debbugs.git/blobdiff - lib/Debbugs/Log.pm
move Debbugs to lib
[debbugs.git] / lib / Debbugs / Log.pm
diff --git a/lib/Debbugs/Log.pm b/lib/Debbugs/Log.pm
new file mode 100644 (file)
index 0000000..710a844
--- /dev/null
@@ -0,0 +1,589 @@
+# This module is part of debbugs, and is released
+# under the terms of the GPL version 2, or any later
+# version at your option.
+# See the file README and COPYING for more information.
+#
+# [Other people have contributed to this file; their copyrights should
+# go here too.]
+# Copyright 2004 by Collin Watson <cjwatson@debian.org>
+# Copyright 2007 by Don Armstrong <don@donarmstrong.com>
+
+
+package Debbugs::Log;
+
+use Mouse;
+use strictures 2;
+use namespace::clean;
+use v5.10; # for state
+
+use vars qw($VERSION $DEBUG @EXPORT @EXPORT_OK %EXPORT_TAGS);
+use Exporter qw(import);
+
+BEGIN {
+    $VERSION = 1.00;
+    $DEBUG = 0 unless defined $DEBUG;
+
+    @EXPORT = ();
+    %EXPORT_TAGS = (write => [qw(write_log_records),
+                            ],
+                   read  => [qw(read_log_records record_text record_regex),
+                            ],
+                   misc  => [qw(escape_log),
+                            ],
+                  );
+    @EXPORT_OK = ();
+    Exporter::export_ok_tags(qw(write read misc));
+    $EXPORT_TAGS{all} = [@EXPORT_OK];
+}
+
+use Carp;
+
+use Debbugs::Common qw(getbuglocation getbugcomponent make_list);
+use Params::Validate qw(:types validate_with);
+use Encode qw(encode encode_utf8 is_utf8);
+use IO::InnerFile;
+
+=head1 NAME
+
+Debbugs::Log - an interface to debbugs .log files
+
+=head1 DESCRIPTION
+
+The Debbugs::Log module provides a convenient way for scripts to read and
+write the .log files used by debbugs to store the complete textual records
+of all bug transactions.
+
+Debbugs::Log does not decode utf8 into perl's internal encoding or
+encode into utf8 from perl's internal encoding. For html records and
+all recips, this should probably be done. For other records, this should
+not be needed.
+
+=head2 The .log File Format
+
+.log files consist of a sequence of records, of one of the following four
+types. ^A, ^B, etc. represent those control characters.
+
+=over 4
+
+=item incoming-recv
+
+  ^G
+  [mail]
+  ^C
+
+C<[mail]> must start with /^Received: \(at \S+\) by \S+;/, and is copied to
+the output.
+
+=item autocheck
+
+Auto-forwarded messages are recorded like this:
+
+  ^A
+  [mail]
+  ^C
+
+C<[mail]> must contain /^X-Debian-Bugs(-\w+)?: This is an autoforward from
+\S+/. The first line matching that is removed; all lines in the message body
+that begin with 'X' will be copied to the output, minus the 'X'.
+
+Nothing in debbugs actually generates this record type any more, but it may
+still be in old .logs at some sites.
+
+=item recips
+
+  ^B
+  [recip]^D[recip]^D[...] OR -t
+  ^E
+  [mail]
+  ^C
+
+Each [recip] is output after "Message sent"; C<-t> represents the same
+sendmail option, indicating that the recipients are taken from the headers
+of the message itself.
+
+=item html
+
+  ^F
+  [html]
+  ^C
+
+[html] is copied unescaped to the output. The record immediately following
+this one is considered "boring" and only shown in certain output modes.
+
+(This is a design flaw in the log format, since it makes it difficult to
+change the HTML presentation later, or to present the data in an entirely
+different format.)
+
+=back
+
+No other types of records are permitted, and the file must end with a ^C
+line.
+
+=cut
+
+my %states = (
+    1 => 'autocheck',
+    2 => 'recips',
+    3 => 'kill-end',
+    5 => 'go',
+    6 => 'html',
+    7 => 'incoming-recv',
+);
+
+=head2 Perl Record Representation
+
+Each record is a hash. The C<type> field is C<incoming-recv>, C<autocheck>,
+C<recips>, or C<html> as above; C<text> contains text from C<[mail]> or
+C<[html]> as above; C<recips> is a reference to an array of recipients
+(strings), or undef for C<-t>.
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item new
+
+Creates a new log reader based on a .log filehandle.
+
+      my $log = Debbugs::Log->new($logfh);
+      my $log = Debbugs::Log->new(bug_num => $nnn);
+      my $log = Debbugs::Log->new(logfh => $logfh);
+
+Parameters
+
+=over
+
+=item bug_num -- bug number
+
+=item logfh -- log filehandle
+
+=item log_name -- name of log
+
+=back
+
+One of the above options must be passed.
+
+=cut
+
+sub BUILD {
+    my ($self,$args) = @_;
+    if (not ($self->_has_bug_num or
+             $self->_has_logfh or
+             $self->_has_log_name)) {
+        croak "Exactly one of bug_num, logfh, or log_name ".
+            "must be passed and must be defined";
+    }
+}
+
+has 'bug_num' =>
+    (is => 'ro',
+     isa => 'Int',
+     predicate => '_has_bug_num',
+    );
+
+has 'logfh' =>
+    (is => 'ro',
+     lazy => 1,
+     builder => '_build_logfh',
+     predicate => '_has_logfh',
+    );
+
+sub _build_logfh {
+    my $self = shift;
+    my $bug_log =
+        $self->log_name;
+    my $log_fh;
+    if ($bug_log =~ m/\.gz$/) {
+        my $oldpath = $ENV{'PATH'};
+        $ENV{'PATH'} = '/bin:/usr/bin';
+        open($log_fh,'-|','gzip','-dc',$bug_log) or
+            die "Unable to open $bug_log for reading: $!";
+        $ENV{'PATH'} = $oldpath;
+    } else {
+        open($log_fh,'<',$bug_log) or
+            die "Unable to open $bug_log for reading: $!";
+    }
+    return $log_fh;
+}
+
+has 'log_name' =>
+    (is => 'ro',
+     isa => 'Str',
+     lazy => 1,
+     builder => '_build_log_name',
+     predicate => '_has_log_name',
+    );
+
+sub _build_log_name {
+    my $self = shift;
+    my $location = getbuglocation($self->bug_num,'log');
+    return getbugcomponent($self->bug_num,'log',$location);
+}
+
+has 'inner_file' =>
+    (is => 'ro',
+     isa => 'Bool',
+     default => 0,
+    );
+
+has 'state' =>
+    (is => 'ro',
+     isa => 'Str',
+     default => 'kill-init',
+     writer => '_state',
+    );
+
+sub state_transition {
+    my $self = shift;
+    my $new_state = shift;
+    my $old_state = $self->state;
+    local $_ = "$old_state $new_state";
+    unless (/^(go|go-nox|html) kill-end$/ or
+            /^(kill-init|kill-end) (incoming-recv|autocheck|recips|html)$/ or
+            /^autocheck autowait$/ or
+            /^autowait go-nox$/ or
+            /^recips kill-body$/ or
+            /^(kill-body|incoming-recv) go$/) {
+        confess "transition from $old_state to $new_state at $self->linenum disallowed";
+    }
+    $self->_state($new_state);
+}
+
+sub increment_linenum {
+    my $self = shift;
+    $self->_linenum($self->_linenum+1);
+}
+has '_linenum' =>
+    (is => 'rw',
+     isa => 'Int',
+     default => 0,
+    );
+
+=item read_record
+
+Reads and returns a single record from a log reader object. At end of file,
+returns undef. Throws exceptions using die(), so you may want to wrap this
+in an eval().
+
+=cut
+
+sub read_record
+{
+    my $this = shift;
+    my $logfh = $this->logfh;
+
+    # This comes from bugreport.cgi, but is much simpler since it doesn't
+    # worry about the details of output.
+
+    my $record = {};
+
+    while (defined (my $line = <$logfh>)) {
+        $record->{start} = $logfh->tell() if not defined $record->{start};
+       chomp $line;
+       $this->increment_linenum;
+       if (length($line) == 1 and exists $states{ord($line)}) {
+           # state transitions
+           $this->state_transition($states{ord($line)});
+           if ($this->state =~ /^(autocheck|recips|html|incoming-recv)$/) {
+                $record->{type} = $this->state;
+                $record->{start} = $logfh->tell;
+                $record->{stop} = $logfh->tell;
+                $record->{inner_file} = $this->inner_file;
+           } elsif ($this->state eq 'kill-end') {
+                if ($this->inner_file) {
+                    $record->{fh} =
+                        IO::InnerFile->new($logfh,$record->{start},
+                                           $record->{stop} - $record->{start})
+                        }
+               return $record;
+           }
+
+           next;
+       }
+        $record->{stop} = $logfh->tell;
+       $_ = $line;
+       if ($this->state eq 'incoming-recv') {
+           my $pl = $_;
+           unless (/^Received: \(at \S+\) by \S+;/) {
+               die "bad line '$pl' in state incoming-recv";
+           }
+           $this->state_transition('go');
+           $record->{text} .= "$_\n" unless $this->inner_file;
+       } elsif ($this->state eq 'html') {
+           $record->{text} .= "$_\n"  unless $this->inner_file;
+       } elsif ($this->state eq 'go') {
+           s/^\030//;
+           $record->{text} .= "$_\n"  unless $this->inner_file;
+       } elsif ($this->state eq 'go-nox') {
+           $record->{text} .= "$_\n"  unless $this->inner_file;
+       } elsif ($this->state eq 'recips') {
+           if (/^-t$/) {
+               undef $record->{recips};
+           } else {
+               # preserve trailing null fields, e.g. #2298
+               $record->{recips} = [split /\04/, $_, -1];
+           }
+           $this->state_transition('kill-body');
+            $record->{start} = $logfh->tell+2;
+            $record->{stop} = $logfh->tell+2;
+            $record->{inner_file} = $this->inner_file;
+       } elsif ($this->state eq 'autocheck') {
+           $record->{text} .= "$_\n" unless $this->inner_file;
+           next if !/^X-Debian-Bugs(-\w+)?: This is an autoforward from (\S+)/;
+           $this->state_transition('autowait');
+       } elsif ($this->state eq 'autowait') {
+           $record->{text} .= "$_\n" unless $this->inner_file;
+           next if !/^$/;
+           $this->state_transition('go-nox');
+       } else {
+           die "state $this->state at line $this->linenum ('$_')";
+       }
+    }
+    die "state $this->state at end" unless $this->state eq 'kill-end';
+
+    if (keys %$record) {
+       return $record;
+    } else {
+       return undef;
+    }
+}
+
+=item rewind
+
+Rewinds the Debbugs::Log to the beginning
+
+=cut
+
+sub rewind {
+    my $self = shift;
+    if ($self->_has_log_name) {
+        $self->_clear_log_fh;
+    } else {
+        $self->log_fh->seek(0);
+    }
+    $self->_state('kill-init');
+    $self->_linenum(0);
+}
+
+=item read_all_records
+
+Reads all of the Debbugs::Records
+
+=cut
+
+sub read_all_records {
+    my $self = shift;
+    if ($self->_linenum != 0) {
+        $self->rewind;
+    }
+    my @records;
+    while (defined(my $record = $self->read_record())) {
+       push @records, $record;
+    }
+    return @records;
+}
+
+
+=item read_log_records
+
+Takes a .log filehandle as input, and returns an array of all records in
+that file. Throws exceptions using die(), so you may want to wrap this in an
+eval().
+
+Uses exactly the same options as Debbugs::Log::new
+
+=cut
+
+sub read_log_records
+{
+    my %param;
+    if (@_ == 1) {
+        ($param{logfh}) = @_;
+    }
+    else {
+        %param = validate_with(params => \@_,
+                               spec   => {bug_num => {type => SCALAR,
+                                                      optional => 1,
+                                                     },
+                                          logfh   => {type => HANDLE,
+                                                      optional => 1,
+                                                     },
+                                          log_name => {type => SCALAR,
+                                                       optional => 1,
+                                                      },
+                           inner_file => {type => BOOLEAN,
+                                          default => 0,
+                                         },
+                                         }
+                              );
+    }
+    if (grep({exists $param{$_} and defined $param{$_}} qw(bug_num logfh log_name)) ne 1) {
+        croak "Exactly one of bug_num, logfh, or log_name must be passed and must be defined";
+    }
+
+    my @records;
+    my $reader = Debbugs::Log->new(%param);
+    while (defined(my $record = $reader->read_record())) {
+       push @records, $record;
+    }
+    return @records;
+}
+
+=item write_log_records
+
+Takes a filehandle and a list of records as input, and prints the .log
+format representation of those records to that filehandle.
+
+=back
+
+=cut
+
+sub write_log_records
+{
+    my %param = validate_with(params => \@_,
+                             spec   => {bug_num => {type => SCALAR,
+                                                    optional => 1,
+                                                   },
+                                        logfh   => {type => HANDLE,
+                                                    optional => 1,
+                                                   },
+                                        log_name => {type => SCALAR,
+                                                     optional => 1,
+                                                    },
+                                        records => {type => HASHREF|ARRAYREF,
+                                                   },
+                                       },
+                            );
+    if (grep({exists $param{$_} and defined $param{$_}} qw(bug_num logfh log_name)) ne 1) {
+        croak "Exactly one of bug_num, logfh, or log_name must be passed and must be defined";
+    }
+    my $logfh;
+    if (exists $param{logfh}) {
+        $logfh = $param{logfh}
+    }
+    elsif (exists $param{log_name}) {
+        $logfh = IO::File->new(">>$param{log_name}") or
+             die "Unable to open bug log $param{log_name} for writing: $!";
+    }
+    elsif (exists $param{bug_num}) {
+        my $location = getbuglocation($param{bug_num},'log');
+        my $bug_log = getbugcomponent($param{bug_num},'log',$location);
+        $logfh = IO::File->new($bug_log, 'r') or
+             die "Unable to open bug log $bug_log for reading: $!";
+    }
+    my @records = make_list($param{records});
+
+    for my $record (@records) {
+       my $type = $record->{type};
+       croak "record type '$type' with no text field" unless defined $record->{text};
+       # I am not sure if we really want to croak here; but this is
+       # almost certainly a bug if is_utf8 is on.
+        my $text = $record->{text};
+        if (is_utf8($text)) {
+            carp('Record text was in the wrong encoding (perl internal instead of utf8 octets)');
+            $text = encode_utf8($text)
+        }
+       ($text) = escape_log($text);
+       if ($type eq 'autocheck') {
+           print {$logfh} "\01\n$text\03\n" or
+               die "Unable to write to logfile: $!";
+       } elsif ($type eq 'recips') {
+           print {$logfh} "\02\n";
+           my $recips = $record->{recips};
+           if (defined $recips) {
+               croak "recips not undef or array"
+                   unless ref($recips) eq 'ARRAY';
+                my $wrong_encoding = 0;
+                my @recips =
+                    map { if (is_utf8($_)) {
+                        $wrong_encoding=1;
+                        encode_utf8($_);
+                    } else {
+                        $_;
+                    }} @$recips;
+                carp('Recipients was in the wrong encoding (perl internal instead of utf8 octets') if $wrong_encoding;
+               print {$logfh} join("\04", @$recips) . "\n" or
+                   die "Unable to write to logfile: $!";
+           } else {
+               print {$logfh} "-t\n" or
+                   die "Unable to write to logfile: $!";
+           }
+           #$text =~ s/^([\01-\07\030])/\030$1/gm;
+           print {$logfh} "\05\n$text\03\n" or
+               die "Unable to write to logfile: $!";
+       } elsif ($type eq 'html') {
+           print {$logfh} "\06\n$text\03\n" or
+               die "Unable to write to logfile: $!";
+       } elsif ($type eq 'incoming-recv') {
+           #$text =~ s/^([\01-\07\030])/\030$1/gm;
+           print {$logfh} "\07\n$text\03\n" or
+               die "Unable to write to logfile: $!";
+       } else {
+           croak "unknown record type type '$type'";
+       }
+    }
+
+    1;
+}
+
+=head2 escape_log
+
+     print {$log} escape_log(@log)
+
+Applies the log escape regex to the passed logfile.
+
+=cut
+
+sub escape_log {
+       my @log = @_;
+       return map {s/^([\01-\07\030])/\030$1/gm; $_ } @log;
+}
+
+
+sub record_text {
+    my ($record) = @_;
+    if ($record->{inner_file}) {
+        local $/;
+        my $text;
+        my $t = $record->{fh};
+        $text = <$t>;
+        $record->{fh}->seek(0,0);
+        return $text;
+    } else {
+        return $record->{text};
+    }
+}
+
+sub record_regex {
+    my ($record,$regex) = @_;
+    if ($record->{inner_file}) {
+        my @result;
+        my $fh = $record->{fh};
+        while (<$fh>) {
+            if (@result = $_ =~ m/$regex/) {
+                $record->{fh}->seek(0,0);
+                return @result;
+            }
+        }
+        $record->{fh}->seek(0,0);
+        return ();
+    } else {
+        my @result = $record->{text} =~ m/$regex/;
+        return @result;
+    }
+}
+
+
+=head1 CAVEATS
+
+This module does none of the formatting that bugreport.cgi et al do. It's
+simply a means for extracting and rewriting raw records.
+
+=cut
+
+1;
+
+# Local Variables:
+# indent-tabs-mode: nil
+# cperl-indent-level: 4
+# End: