summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYves Orton <demerphq@gmail.com>2022-08-11 16:02:57 +0200
committerYves Orton <demerphq@gmail.com>2022-08-21 12:09:05 +0200
commit40c26f0c37ae90c90b3db1887343c857d0f78d49 (patch)
treeacd5ed5e237e5fc74cb4a0cd24ca40f06ab21047
parent921bc9a5068bda5c083f97cd2fd08273db3870a9 (diff)
downloadperl-40c26f0c37ae90c90b3db1887343c857d0f78d49.tar.gz
updateAUTHORS.p[lm] - add support for reports like checkAUTHORS.pl has
Adds the --stats, --files, --who, and related options similar to what checkAUTHORS.pl offers. See perldoc Porting/updateAUTHORS.pl for the list of options it supports.
-rwxr-xr-xPorting/updateAUTHORS.pl170
-rw-r--r--Porting/updateAUTHORS.pm240
-rw-r--r--t/porting/update_authors.t164
3 files changed, 572 insertions, 2 deletions
diff --git a/Porting/updateAUTHORS.pl b/Porting/updateAUTHORS.pl
index 0e41bb01a9..8547811546 100755
--- a/Porting/updateAUTHORS.pl
+++ b/Porting/updateAUTHORS.pl
@@ -26,8 +26,32 @@ my @OPTSPEC= qw(
exclude_contrib=s@
exclude_me
+ show_rank|rank
+ show_applied|thanks_applied|applied
+ show_stats|stats
+ show_who|who
+ show_files|files
+ show_file_changes|activity
+ show_file_chainsaw|chainsaw
+
+ as_percentage|percentage
+ as_cumulative|cumulative
+ as_list|old_style
+
+ in_reverse|reverse
+ with_rank_numbers|numbered|num
+
from_commit|from=s
to_commit|to=s
+
+ numstat
+ no_update
+);
+
+my %implies_numstat= (
+ show_files => 1,
+ show_file_changes => 1,
+ show_file_chainsaw => 1,
);
sub main {
@@ -63,6 +87,11 @@ sub main {
pod2usage(1) if $opts{help};
pod2usage(-verbose => 2) if $opts{man};
+ foreach my $opt (keys %opts) {
+ $opts{numstat}++ if $implies_numstat{$opt};
+ $opts{no_update}++ if $opt =~ /^show_/;
+ }
+
if (delete $opts{exclude_me}) {
my ($author_full)=
Porting::updateAUTHORS->current_author_name_email("full");
@@ -81,6 +110,48 @@ sub main {
my $changed= $self->read_and_update();
+ if ($self->{show_rank}) {
+ $self->report_stats("who_stats", "author");
+ return 0;
+ }
+ elsif ($self->{show_applied}) {
+ $self->report_stats("who_stats", "applied");
+ return 0;
+ }
+ elsif ($self->{show_stats}) {
+ my @fields= ("author", "applied", "committer");
+ push @fields,
+ ("num_files", "lines_added", "lines_removed", "lines_delta")
+ if $self->{numstat};
+ $self->report_stats("who_stats", @fields);
+ return 0;
+ }
+ elsif ($self->{show_files}) {
+ $self->report_stats(
+ "file_stats", "commits", "lines_added", "lines_removed",
+ "lines_delta", "binary_change"
+ );
+ return 0;
+ }
+ elsif ($self->{show_file_changes}) {
+ $self->report_stats(
+ "file_stats", "lines_delta", "lines_added", "lines_removed",
+ "commits"
+ );
+ return 0;
+ }
+ elsif ($self->{show_file_chainsaw}) {
+ $self->{in_reverse}= !$self->{in_reverse};
+ $self->report_stats(
+ "file_stats", "lines_delta", "lines_added", "lines_removed",
+ "commits"
+ );
+ return 0;
+ }
+ elsif ($self->{show_who}) {
+ $self->print_who();
+ return 0;
+ }
return $changed; # 0 means nothing changed
}
@@ -116,6 +187,7 @@ are properly listed.
--mailmap-file=FILE override default of '.mailmap'
Action Modifiers
+ --no-update Do not update.
--exclude-missing Add new names to the exclude file so they never
appear in AUTHORS or .mailmap.
@@ -124,6 +196,27 @@ are properly listed.
--exclude-contrib NAME_AND_EMAIL
--exclude-me
+ Reports About People
+ --stats detailed report of authors and what they did
+ --who Sorted, wrapped list of who did what
+ --thanks-applied report who applied stuff for others
+ --rank report authors by number of commits created
+
+ Reports About Files
+ --files detailed report files that were modified
+ --activity simple report of files that grew the most
+ --chainsaw simple report of files that shrank the most
+
+ Report Modifiers
+ --percentage show percentages not counts
+ --cumulative show cumulative numbers not individual
+ --reverse show reports in reverse order
+ --numstat show additional file based data in some reports
+ (not needed for most reports)
+ --as-list show reports with names with common values
+ folded into a list like checkAUTHORS.pl used to
+ --numbered add rank numbers to reports where they are missing
+
=head1 OPTIONS
=over 4
@@ -140,6 +233,10 @@ Prints the manual page and exits.
Be verbose about what is happening. Can be repeated more than once.
+=item C<--no-update>
+
+Do not update files on disk even if they need to be changed.
+
=item C<--authors-file=FILE>
=item C<--authors_file=FILE>
@@ -208,6 +305,79 @@ tool with the C<--exclude> option. It is probably a good idea to run it
first without any arguments to make sure you dont exclude something or
someone you did not intend to.
+=item C<--stats>
+
+Show detailed stats about committers and the work they did in a tabular
+form. If the C<--numstat> option is provided this report will provide
+additional data about the files a developer worked on. May be slow the
+first time it is used as git unpacks the relevant data.
+
+=item C<--who>
+
+Show a list of which committers and authors contributed to the project
+in the selected range of commits. The list will contain the name only,
+and will sorted according to unicode collation rules. This list is
+suitable in release notes and similar contexts.
+
+=item C<--thanks-applied>
+
+Show a report of which committers applied work on behalf of
+someone else, including counts. Modified by the C<--as-list> and
+C<--display-rank>.
+
+=item C<--rank>
+
+Shows a report of which commits did the most work. Modified by the
+C<--as-list> and C<--display-rank> options.
+
+=item C<--files>
+
+Show detailed stats about the files that have been modified in the
+selected range of commits. Implies C<--numstat>. May be slow the first
+time it is used as git unpacks the relevant data.
+
+=item C<--activity>
+
+Show simple stats about which files had the most additions. Implies
+C<--numstat>. May be slow the first time it is used as git unpacks the
+relevant data.
+
+
+=item C<--chainsaw>
+
+Show simple stats about whcih files had the most removals. Implies
+C<--numstat>. May be slow the first time it is used as git unpacks the
+relevant data.
+
+=item C<--percentage>
+
+Show numeric data as percentages of the total, not counts.
+
+=item C<--cumulative>
+
+Show numeric data as cumulative counts in the reports.
+
+=item C<--reverse>
+
+Show the reports in reverse order to normal.
+
+=item C<--numstat>
+
+Gather additional data about the files that were changed, not just the
+authors who did the changes. This option currently is only necessary for
+the C<--stats> option, which will display additional data when this
+option is also provided.
+
+=item C<--as-list>
+
+Show the reports with name data rolled up together into a list like the
+older checkAUTHORS.pl script would have.
+
+=item C<--numbered>
+
+Show an additional column with the rank number of a row in the report in
+reports that do not normally show the rank number.
+
=back
=head1 DESCRIPTION
diff --git a/Porting/updateAUTHORS.pm b/Porting/updateAUTHORS.pm
index 835b6b4bbd..3cefe69bef 100644
--- a/Porting/updateAUTHORS.pm
+++ b/Porting/updateAUTHORS.pm
@@ -4,6 +4,9 @@ use warnings;
use Data::Dumper;
use Encode qw(encode_utf8 decode_utf8 decode);
use Digest::SHA qw(sha256_base64);
+use Text::Wrap qw(wrap);
+use Unicode::Collate;
+$Text::Wrap::columns= 80;
# The style of this file is determined by:
#
@@ -39,9 +42,10 @@ my %field_spec= (
"s" => "commit_subject",
);
+my $Collate= Unicode::Collate->new(level => 1, indentical => 1);
my @field_codes= sort keys %field_spec;
my @field_names= map { $field_spec{$_} } @field_codes;
-my $tformat= join "%x00", map { "%" . $_ } @field_codes;
+my $tformat= "=" . join "%x00", map { "%" . $_ } @field_codes;
sub _make_name_author_info {
my ($self, $commit_info, $name_key)= @_;
@@ -83,6 +87,8 @@ sub _register_author {
my $digest= $self->_keeper_digest($name)
or return;
+ $self->{who_stats}{$name}{$type}++;
+
$self->{author_info}{"lines"}{$name}
and return;
@@ -147,6 +153,46 @@ sub current_author_name_email {
return $full ? $self->format_name_email($n, $e) : ($n, $e);
}
+sub finalize_commit_info {
+ my ($self, $commit_info)= @_;
+ my $author= $commit_info->{author_name_mm_canon};
+ my $author_stats= $self->{who_stats}{$author} ||= {};
+
+ my $file_info= $commit_info->{files} ||= {};
+ foreach my $file (keys %{$file_info}) {
+ if (!$self->{file_stats}{$file}) {
+ $self->{summary_stats}{num_files}++;
+ }
+ my $fs= $self->{file_stats}{$file} ||= {};
+ my $afs= $author_stats->{file_stats}{$file} ||= {};
+ my $added= $file_info->{$file}{lines_added};
+ my $removed= $file_info->{$file}{lines_removed};
+ my $delta= $file_info->{$file}{lines_delta};
+ defined $_ and $_ eq "-" and undef $_ for $added, $removed;
+
+ if (defined $added) {
+ for my $h ($author_stats, $fs, $afs) {
+ $h->{lines_delta} += $delta;
+ $h->{lines_added} += $added;
+ $h->{lines_removed} += $removed;
+ }
+ }
+ else {
+ $author_stats->{binary_change}++;
+ $fs->{binary_change}++;
+ $afs->{binary_change}++;
+ }
+ $afs->{commits}++
+ or $author_stats->{num_files}++;
+
+ $fs->{commits}++
+ or $self->{summary_stats}{num_files}++;
+
+ $fs->{who}{$author}++
+ or $self->{summary_stats}{authors}++;
+ }
+}
+
sub read_commit_log {
my ($self)= @_;
my $author_info= $self->{author_info} ||= {};
@@ -155,13 +201,39 @@ sub read_commit_log {
my $commit_range= $self->{commit_range};
my $commits_read= 0;
- my $cmd= qq(git log --pretty='format:$tformat' $commit_range);
+ my $numstat= $self->{numstat} ? "--numstat" : "";
+
+ my $last_commit_info;
+ my $cmd= qq(git log --pretty='format:$tformat' $numstat $commit_range);
open my $fh, "$cmd |";
while (defined(my $line= <$fh>)) {
chomp $line;
$line= decode_utf8($line);
+ if ($line =~ s/^=//) {
+ $self->finalize_commit_info($last_commit_info)
+ if $last_commit_info;
+ }
+ elsif ($line =~ /\S/) {
+ my ($added, $removed, $file)= split /\s+/, $line;
+ if ($added ne "-") {
+ $last_commit_info->{files}{$file}= {
+ lines_added => $added,
+ lines_removed => $removed,
+ lines_delta => $added - $removed,
+ };
+ }
+ else {
+ $last_commit_info->{files}{$file}{binary_changes}++;
+ }
+ next;
+ }
+ else {
+ # whitspace only or empty line
+ next;
+ }
$commits_read++;
my $commit_info= {};
+ $last_commit_info= $commit_info;
@{$commit_info}{@field_names}= split /\0/, $line, 0 + @field_names;
my $author_name_mm_canon=
@@ -175,12 +247,15 @@ sub read_commit_log {
my $committer_name_real=
$self->_make_name_simple($commit_info, "committer");
+ my ($author_good, $committer_good);
+
if ( $self->_keeper_digest($author_name_mm_canon)
&& $self->_keeper_digest($author_name_real))
{
$self->_check_name_mailmap($author_name_mm_canon, $author_name_real,
$commit_info, "author name");
$self->_register_author($author_name_mm_canon, "author");
+ $author_good= 1;
}
if ( $self->_keeper_digest($committer_name_mm_canon)
@@ -189,8 +264,16 @@ sub read_commit_log {
$self->_check_name_mailmap($committer_name_mm_canon,
$committer_name_real, $commit_info, "committer name");
$self->_register_author($committer_name_mm_canon, "committer");
+ $committer_good= 1;
+ }
+ if ( $author_good
+ and $committer_good
+ and $committer_name_mm_canon ne $author_name_mm_canon)
+ {
+ $self->{who_stats}{$committer_name_mm_canon}{applied}++;
}
}
+ $self->finalize_commit_info($last_commit_info) if $last_commit_info;
if (!$commits_read) {
if ($self->{commit_range}) {
die "No commits in range '$self->{commit_range}'\n";
@@ -727,6 +810,11 @@ sub update_exclude_file {
if ($exclude_text ne $self->{exclude_file_text_orig}) {
$self->{changed_count}++;
$self->{changed_file}{$exclude_file}++;
+
+ if ($self->{no_update}) {
+ return 1;
+ }
+
warn "Updating '$exclude_file'\n" if $self->{verbose};
my $tmp_file= "$exclude_file.new";
@@ -835,6 +923,154 @@ sub _exclude_contrib {
or warn "Excluding '$name' with '$digest'\n";
}
+my %pretty_name= (
+ "author" => "Authored",
+ "committer" => "Committed",
+ "applied" => "Applied",
+ "name" => "Name",
+ "pos" => "Pos",
+ "num_files" => "NFiles",
+ "lines_added" => "L++",
+ "lines_removed" => "L--",
+ "lines_delta" => "L+-",
+ "binary_changed" => "Bin+-",
+);
+
+sub report_stats {
+ my ($self, $stats_key, @types)= @_;
+ my @extra= "name";
+ my @rows;
+ my @total;
+ foreach my $name (__sorted_hash_keys($self->{$stats_key})) {
+ my @data= map { $self->{$stats_key}{$name}{$_} // 0 } @types;
+ $total[$_] += $data[$_] for 0 .. $#data;
+ push @data, $name;
+ push @rows, \@data if $data[0];
+ }
+ @rows= sort {
+ my $cmp= 0;
+ for (0 .. $#$a - 1) {
+ $cmp= $b->[$_] <=> $a->[$_];
+ last if $cmp;
+ }
+ $cmp ||= $Collate->cmp($a->[-1], $b->[-1]);
+ $cmp
+ } @rows;
+ @rows= reverse @rows if $self->{in_reverse};
+
+ if ($self->{as_cumulative}) {
+ my $sum= [];
+ for my $row (@rows) {
+ do {
+ $sum->[$_] += $row->[$_];
+ $row->[$_]= $sum->[$_];
+ }
+ for 0 .. $#types;
+ }
+ }
+
+ if ($self->{as_percentage}) {
+ for my $row (@rows) {
+ $row->[$_]= sprintf "%.2f", ($row->[$_] / $total[$_]) * 100
+ for 0 .. $#types;
+ }
+ }
+
+ foreach my $row (@rows) {
+ my $name= $row->[-1];
+ $name =~ s/\s+<.*\z//;
+ $name =~ s/\s+\@.*\z//;
+ $row->[-1]= $name;
+ }
+ my @col_names= map { $pretty_name{$_} // $_ } @types;
+ if ($self->{as_percentage}) {
+ $_= "%$_" for @col_names;
+ }
+ push @col_names, map { $pretty_name{$_} // $_ } @extra;
+
+ if ($self->{as_list} && @types == 1) {
+ $self->_report_list(\@rows, \@types, \@extra, \@col_names);
+ }
+ else {
+ $self->_report_table(\@rows, \@types, \@extra, \@col_names);
+ }
+}
+
+sub _report_table {
+ my ($self, $rows, $types, $extra, $col_names)= @_;
+ my $pos= 1;
+ unshift @$_, $pos++ for @$rows;
+ unshift @$col_names, "Pos";
+ my @width= (0) x @$col_names;
+ foreach my $row ($col_names, @$rows) {
+ for my $idx (0 .. $#$row) {
+ $width[$idx] < length($row->[$idx])
+ and $width[$idx]= length($row->[$idx]);
+ }
+ }
+ $width[-1]= 40 if $width[-1] > 40;
+ $width[$_]= -$width[$_] for 0, -1;
+ my $fmt= "#" . join(" | ", ("%*s") x @$col_names) . "\n";
+ my $bar_fmt= "#" . join("-+-", ("%*s") x @$col_names) . "\n";
+ printf $fmt, map { $width[$_], $col_names->[$_] } 0 .. $#width;
+ printf $bar_fmt, map { $width[$_], "-" x abs($width[$_]) } 0 .. $#width;
+ for my $idx (0 .. $#$rows) {
+ my $row= $rows->[$idx];
+ print encode_utf8 sprintf $fmt,
+ map { $width[$_], $row->[$_] } 0 .. $#width;
+ }
+}
+
+sub _report_list {
+ my ($self, $rows, $types, $extra, $col_names)= @_;
+ my %hash;
+ foreach my $row (@$rows) {
+ $hash{ $row->[0] }{ $row->[-1] }++;
+ }
+ my @vals= sort { $b <=> $a } keys %hash; # numeric sort
+ my $width= length($col_names->[0]);
+ $width < length($_) and $width= length($_) for @vals;
+ @vals= reverse @vals if $self->{in_reverse};
+
+ my $hdr_str= sprintf "%*s | %s", $width, $col_names->[0], $col_names->[-1];
+ my $sep_str= sprintf "%*s-+-%s", $width, "-" x $width, "-" x 40;
+ my $fmt= "%*s | %s";
+
+ if ($self->{with_rank_numbers}) {
+ $hdr_str= sprintf "#%*s | %s", -length(0 + @$rows), "Pos", $hdr_str;
+ $sep_str= sprintf "#%*s-+-%s", -length(0 + @$rows),
+ "-" x length(0 + @$rows), $hdr_str;
+ }
+ print $hdr_str, "\n";
+ print $sep_str, "\n";
+ my $pos= 1;
+ foreach my $val (@vals) {
+ my $val_f= sprintf "%*s | ", $width, $val;
+ $val_f= sprintf "#%*d | %s", -length(0 + @$rows), $pos++, $val_f
+ if $self->{with_rank_numbers};
+ print encode_utf8 wrap $val_f,
+ " " x length($val_f),
+ join(", ", $Collate->sort(keys %{ $hash{$val} })) . "\n";
+ }
+}
+
+sub _filter_sort_who {
+ my ($self, $hash)= @_;
+ my @who;
+ foreach my $name ($Collate->sort(keys %$hash)) {
+ $name =~ s/\s+<.*\z//;
+ $name =~ s/\s+\@.*\z//;
+ push @who, $name if length $name and lc($name) ne "unknown";
+ }
+ return @who;
+}
+
+sub print_who {
+ my ($self)= @_;
+ my @who= $self->_filter_sort_who($self->{who_stats});
+ print encode_utf8 wrap "", "", join(", ", @who) . ".\n";
+}
+
1;
__END__
diff --git a/t/porting/update_authors.t b/t/porting/update_authors.t
index 9bd76a9671..589deeb3f7 100644
--- a/t/porting/update_authors.t
+++ b/t/porting/update_authors.t
@@ -15,5 +15,169 @@ my $ok= do "./Porting/updateAUTHORS.pl";
my $error= !$ok && $@;
is($ok,1,"updateAUTHORS.pl compiles correctly");
is($error, "", "updateAUTHORS.pl compiles without error");
+my $small_range= "544171f79ec3e50bb5003007e9f4ebb9a7e9fe84^^^"
+ . "..544171f79ec3e50bb5003007e9f4ebb9a7e9fe84";
+my $large_range= "6d02a9e121d037896df9b91ac623c1ab4c98c99a.."
+ . "544171f79ec3e50bb5003007e9f4ebb9a7e9fe8";
+my $with_unknown_range= "96a91e01636d3050d38ae3373a362c7d47a6647e^^^.."
+ . "96a91e01636d3050d38ae3373a362c7d47a6647e";
+foreach my $tuple (
+ [ "--who", $small_range,
+ "James E Keenan, Karl Williamson, Mark Shelor." ],
+ [ "--files", $small_range, files_expected() ],
+ [ "--rank", $large_range, rank_expected()],
+ [ "--rank --percentage", $large_range, rank_percentage_expected()],
+ [ "--rank --percentage --cumulative", $large_range,
+ rank_percentage_cumulative_expected()],
+ [ "--thanks-applied", $large_range, thanks_applied_expected() ],
+ [ "--stats", $large_range, stats_expected() ],
+ [ "--stats --numstat", $large_range, stats_numstat_expected() ],
+ [ "--who" , $with_unknown_range, "Jarkko Hietaniemi.", "(no 'unknown' authors)" ],
+) {
+ my ($arg,$range,$expect, $msg_extra)= @$tuple;
+ my $parsed= `git rev-parse --verify -q $range`;
+ SKIP: {
+ unless ($parsed) {
+ skip "commit range '$range' not available (this happens in CI)", 1;
+ }
+ $msg_extra= $msg_extra ? " $msg_extra" : "";
+ my $cmd= join " ", "$^X ./Porting/updateAUTHORS.pl",
+ $arg, $range;
+ my $result= `$cmd`;
+ is(_clean($result), _clean($expect),"Option '$arg' works as expected$msg_extra")
+ or print STDERR "$cmd\n",$result
+ }
+}
done_testing();
exit 0;
+sub _clean {
+ my ($str)= @_;
+ $str=~s/\s+\z//;
+ $str=~s/[ ]+\n/\n/g;
+ return $str;
+}
+
+sub files_expected {
+ return <<'END_OF_REPORT';
+#Pos | commits | L++ | L-- | L+- | binary_change | Name
+#----+---------+-----+-----+-----+---------------+----------------------------------
+#1 | 1 | 28 | 0 | 28 | 0 | pod/perlfunc.pod
+#2 | 1 | 14 | 4 | 10 | 0 | cpan/Digest-SHA/lib/Digest/SHA.pm
+#3 | 1 | 5 | 5 | 0 | 0 | cpan/Digest-SHA/shasum
+#4 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha64bit.c
+#5 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha64bit.h
+#6 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha.c
+#7 | 1 | 3 | 3 | 0 | 0 | cpan/Digest-SHA/src/sha.h
+#8 | 1 | 1 | 1 | 0 | 0 | Porting/Maintainers.pl
+#9 | 1 | 1 | 0 | 1 | 0 | AUTHORS
+END_OF_REPORT
+}
+
+sub rank_expected {
+ return <<'END_OF_REPORT';
+#Pos | Authored | Name
+#----+----------+-----------------
+#1 | 40 | Karl Williamson
+#2 | 32 | Yves Orton
+#3 | 8 | Paul Evans
+#4 | 6 | James E Keenan
+#5 | 4 | Elvin Aslanov
+#6 | 3 | Richard Leach
+#7 | 3 | Tony Cook
+#8 | 2 | Nicholas Clark
+#9 | 1 | Dan Kogai
+#10 | 1 | David Golden
+#11 | 1 | Graham Knop
+#12 | 1 | Mark Shelor
+#13 | 1 | Tomasz Konojacki
+END_OF_REPORT
+}
+
+sub rank_percentage_expected {
+ return <<'END_OF_REPORT';
+#Pos | %Authored | Name
+#----+-----------+-----------------
+#1 | 38.83 | Karl Williamson
+#2 | 31.07 | Yves Orton
+#3 | 7.77 | Paul Evans
+#4 | 5.83 | James E Keenan
+#5 | 3.88 | Elvin Aslanov
+#6 | 2.91 | Richard Leach
+#7 | 2.91 | Tony Cook
+#8 | 1.94 | Nicholas Clark
+#9 | 0.97 | Dan Kogai
+#10 | 0.97 | David Golden
+#11 | 0.97 | Graham Knop
+#12 | 0.97 | Mark Shelor
+#13 | 0.97 | Tomasz Konojacki
+END_OF_REPORT
+}
+
+sub rank_percentage_cumulative_expected {
+ return <<'END_OF_REPORT';
+#Pos | %Authored | Name
+#----+-----------+-----------------
+#1 | 38.83 | Karl Williamson
+#2 | 69.90 | Yves Orton
+#3 | 77.67 | Paul Evans
+#4 | 83.50 | James E Keenan
+#5 | 87.38 | Elvin Aslanov
+#6 | 90.29 | Richard Leach
+#7 | 93.20 | Tony Cook
+#8 | 95.15 | Nicholas Clark
+#9 | 96.12 | Dan Kogai
+#10 | 97.09 | David Golden
+#11 | 98.06 | Graham Knop
+#12 | 99.03 | Mark Shelor
+#13 | 100.00 | Tomasz Konojacki
+END_OF_REPORT
+}
+
+sub thanks_applied_expected {
+ return <<'END_OF_REPORT';
+#Pos | Applied | Name
+#----+---------+----------------
+#1 | 7 | Karl Williamson
+#2 | 4 | James E Keenan
+END_OF_REPORT
+}
+
+sub stats_expected {
+ return <<'END_OF_REPORT';
+#Pos | Authored | Applied | Committed | Name
+#----+----------+---------+-----------+-----------------
+#1 | 40 | 7 | 47 | Karl Williamson
+#2 | 32 | 0 | 31 | Yves Orton
+#3 | 8 | 0 | 8 | Paul Evans
+#4 | 6 | 4 | 10 | James E Keenan
+#5 | 4 | 0 | 1 | Elvin Aslanov
+#6 | 3 | 0 | 3 | Tony Cook
+#7 | 3 | 0 | 0 | Richard Leach
+#8 | 2 | 0 | 2 | Nicholas Clark
+#9 | 1 | 0 | 1 | Tomasz Konojacki
+#10 | 1 | 0 | 0 | Dan Kogai
+#11 | 1 | 0 | 0 | David Golden
+#12 | 1 | 0 | 0 | Graham Knop
+#13 | 1 | 0 | 0 | Mark Shelor
+END_OF_REPORT
+}
+
+sub stats_numstat_expected {
+ return <<'END_OF_REPORT';
+#Pos | Authored | Applied | Committed | NFiles | L++ | L-- | L+- | Name
+#----+----------+---------+-----------+--------+------+------+------+-----------------
+#1 | 40 | 7 | 47 | 14 | 1179 | 874 | 305 | Karl Williamson
+#2 | 32 | 0 | 31 | 25 | 2547 | 1481 | 1066 | Yves Orton
+#3 | 8 | 0 | 8 | 15 | 161 | 102 | 59 | Paul Evans
+#4 | 6 | 4 | 10 | 4 | 44 | 11 | 33 | James E Keenan
+#5 | 4 | 0 | 1 | 4 | 16 | 13 | 3 | Elvin Aslanov
+#6 | 3 | 0 | 3 | 7 | 8 | 7 | 1 | Tony Cook
+#7 | 3 | 0 | 0 | 13 | 75 | 51 | 24 | Richard Leach
+#8 | 2 | 0 | 2 | 2 | 24 | 1 | 23 | Nicholas Clark
+#9 | 1 | 0 | 1 | 2 | 21 | 15 | 6 | Tomasz Konojacki
+#10 | 1 | 0 | 0 | 8 | 33 | 22 | 11 | Mark Shelor
+#11 | 1 | 0 | 0 | 5 | 93 | 7 | 86 | Graham Knop
+#12 | 1 | 0 | 0 | 4 | 9 | 4 | 5 | Dan Kogai
+#13 | 1 | 0 | 0 | 2 | 19 | 6 | 13 | David Golden
+END_OF_REPORT
+}