The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl
#
# This is a rough draft of a tool to aid in generating a perldelta file
# from a series of git commits.

use 5.010;
use strict;
use warnings;
package Git::DeltaTool;

use Class::Struct;
use File::Basename;
use File::Temp;
use Getopt::Long;
use Git::Wrapper;
use Term::ReadKey;
use Term::ANSIColor;
use Pod::Usage;

BEGIN { struct( git => '$', last_tag => '$', opt => '%' ) }

__PACKAGE__->run;

#--------------------------------------------------------------------------#
# main program
#--------------------------------------------------------------------------#

sub run {
  my $class = shift;

  my %opt = (
    mode => 'assign',
  );

  GetOptions( \%opt,
    # inputs
    'mode|m:s', # 'assign', 'review', 'render', 'update'
    'type|t:s', # select by status
    'status|s:s', # status to set for 'update'
    'since:s', # origin commit
    'help|h',  # help
  );

  pod2usage() if $opt{help};

  my $git = Git::Wrapper->new(".");
  my $git_id = $opt{since};
  if ( defined $git_id ) {
    die "Invalid git identifier '$git_id'\n"
      unless eval { $git->show($git_id); 1 };
  } else {
    ($git_id) = $git->describe;
    $git_id =~ s/-.*$//;
  }
  my $gdt = $class->new( git => $git, last_tag => $git_id, opt => \%opt );

  if ( $opt{mode} eq 'assign' ) {
    $opt{type} //= 'new';
    $gdt->assign;
  }
  elsif ( $opt{mode} eq 'review' ) {
    $opt{type} //= 'pending';
    $gdt->review;
  }
  elsif ( $opt{mode} eq 'render' ) {
    $opt{type} //= 'pending';
    $gdt->render;
  }
  elsif ( $opt{mode} eq 'summary' ) {
    $opt{type} //= 'pending';
    $gdt->summary;
  }
  elsif ( $opt{mode} eq 'update' ) {
    die "Explicit --type argument required for update mode\n"
      unless defined $opt{type};
    die "Explicit --status argument required for update mode\n"
      unless defined $opt{status};
    $gdt->update;
  }
  else {
    die "Unrecognized mode '$opt{mode}'\n";
  }
  exit 0;
}

#--------------------------------------------------------------------------#
# program modes (and iterator)
#--------------------------------------------------------------------------#

sub assign {
  my ($self) = @_;
  my @choices = ( $self->section_choices, $self->action_choices );
  $self->_iterate_commits(
    sub {
      my $log = shift;
      say "";
      say "-" x 75;
      $self->show_header($log);
      $self->show_body($log, 1);
      say "-" x 75;
      return $self->dispatch( $self->prompt( @choices ), $log);
    }
  );
  return;
}

sub review {
  my ($self) = @_;
  my @choices = ( $self->review_choices, $self->action_choices );
  $self->_iterate_commits(
    sub {
      my $log = shift;
      say "";
      say "-" x 75;
      $self->show_header($log);
      $self->show_notes($log, 1);
      say "-" x 75;
      return $self->dispatch( $self->prompt( @choices ), $log);
    }
  );
  return;
}

sub render {
  my ($self) = @_;
  my %sections;
  $self->_iterate_commits(
    sub {
      my $log = shift;
      my $section = $self->note_section($log) or return;
      push @{ $sections{$section} }, $self->note_delta($log);
      return 1;
    }
  );
  my @order = $self->section_order;
  my %known = map { $_ => 1 } @order;
  my @rest = grep { ! $known{$_} } keys %sections;
  for my $s ( @order, @rest ) {
    next unless ref $sections{$s};
    say "-"x75;
    say uc($s) . "\n";
    say join ( "\n", @{ $sections{$s} }, "" );
  }
  return;
}

sub summary {
  my ($self) = @_;
  $self->_iterate_commits(
    sub {
      my $log = shift;
      $self->show_header($log);
      return 1;
    }
  );
  return;
}

sub update {
  my ($self) = @_;

  my $status = $self->opt('status')
    or die "The 'status' option must be supplied for update mode\n";

  $self->_iterate_commits(
    sub {
      my $log = shift;
      my $note = $log->notes;
      $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1$status$2}ms;
      $self->add_note( $log->id, $note );
      return 1;
    }
  );
  return;
}

sub _iterate_commits {
  my ($self, $fcn) = @_;
  my $type = $self->opt('type');
  say "Scanning for $type commits since " . $self->last_tag . "...";
  for my $log ( $self->find_commits($type) ) {
    redo unless $fcn->($log);
  }
  return 1;
}

#--------------------------------------------------------------------------#
# methods
#--------------------------------------------------------------------------#

sub add_note {
  my ($self, $id, $note) = @_;
  my @lines = split "\n", _strip_comments($note);
  pop @lines while @lines && $lines[-1] =~ m{^\s*$};
  my $tempfh = File::Temp->new;
  if (@lines) {
    $tempfh->printflush( join( "\n", @lines), "\n" );
    $self->git->notes('edit', '-F', "$tempfh", $id);
  }
  else {
    $tempfh->printflush( "\n" );
    # git notes won't take an empty file as input
    system("git notes edit -F $tempfh $id");
  }

  return;
}

sub dispatch {
  my ($self, $choice, $log) = @_;
  return unless $choice;
  my $method = "do_$choice->{handler}";
  return 1 unless $self->can($method); # missing methods "succeed"
  return $self->$method($choice, $log);
}

sub edit_text {
  my ($self, $text, $args) = @_;
  $args //= {};
  my $tempfh = File::Temp->new;
  $tempfh->printflush( $text );
  if ( my (@editor) = $ENV{VISUAL} || $ENV{EDITOR} ) {
    push @editor, "-f" if $editor[0] =~ /^gvim/;
    system(@editor, "$tempfh");
  }
  else {
    warn("No VISUAL or EDITOR defined");
  }
  $tempfh->seek(0,0);
  return do { local $/; <$tempfh> };
}

sub find_commits {
  my ($self, $type) = @_;
  $type //= 'new';
  my @commits = $self->git->log($self->last_tag . "..HEAD");
  $_ = Git::Wrapper::XLog->from_log($_) for @commits;
  my @list;
  if ( $type eq 'new' ) {
    @list = grep { ! $_->notes } @commits;
  }
  else {
    @list = grep { $self->note_status( $_ ) eq $type } @commits;
  }
  return @list;
}

sub get_diff {
  my ($self, $log) = @_;
  my @diff = $self->git->show({ stat => 1, p => 1 }, $log->id);
  return join("\n", @diff);
}

sub note_delta {
  my ($self, $log) = @_;
  my @delta = split "\n", ($log->notes || '');
  return '' unless @delta;
  splice @delta, 0, 2;
  return join( "\n", @delta, "" );
}

sub note_section {
  my ($self, $log) = @_;
  my $note = $log->notes or return '';
  my ($section) = $note =~ m{^perldelta:\s*([^\[]*)\s+}ms;
  return $section || '';
}

sub note_status {
  my ($self, $log) = @_;
  my $note = $log->notes or return '';
  my ($status) = $note =~ m{^perldelta:\s*[^\[]*\[(\w+)\]}ms;
  return $status || '';
}

sub note_template {
  my ($self, $log, $text) = @_;
  my $diff = _prepend_comment( $self->get_diff($log) );
  return << "HERE";
# Edit commit note below. Do not change the first line. Comments are stripped
$text

$diff
HERE
}

sub prompt {
  my ($self, @choices) = @_;
  my ($valid, @menu, %keymap) = '';
  for my $c ( map { @$_ } @choices ) {
    my ($item) = grep { /\(/ } split q{ }, $c->{name};
    my ($button) = $item =~ m{\((.)\)};
    die "No key shortcut found for '$item'" unless $button;
    die "Duplicate key shortcut found for '$item'" if $keymap{lc $button};
    push @menu, $item;
    $valid .= lc $button;
    $keymap{lc $button} = $c;
  }
  my $keypress = $self->prompt_key( $self->wrap_list(@menu), $valid );
  return $keymap{lc $keypress};
}

sub prompt_key {
  my ($self, $prompt, $valid_keys) = @_;
  my $key;
  KEY: {
    say $prompt;
    ReadMode 3;
    $key = lc ReadKey(0);
    ReadMode 0;
    if ( $key !~ qr/\A[$valid_keys]\z/i ) {
      say "";
      redo KEY;
    }
  }
  return $key;
}

sub show_body {
  my ($self, $log, $lf) = @_;
  return unless my $body = $log->body;
  say $lf ? "\n$body" : $body;
  return;
}

sub show_header {
  my ($self, $log) = @_;
  my $header = $log->short_id;
  $header .= " " . $log->subject if length $log->subject;
  say colored( $header, "yellow");
  return;
}

sub show_notes {
  my ($self, $log, $lf) = @_;
  return unless my $notes = $log->notes;
  say $lf ? "\n$notes" : $notes;
  return;
}

sub wrap_list {
  my ($self, @list) = @_;
  my $line = shift @list;
  my @wrap;
  for my $item ( @list ) {
    if ( length( $line . $item ) > 70 ) {
      push @wrap, $line;
      $line = $item ne $list[-1] ? $item : "or $item";
    }
    else {
      $line .= $item ne $list[-1] ? ", $item" : " or $item";
    }
  }
  return join("\n", @wrap, $line);
}

sub y_n {
  my ($self, $msg) = @_;
  my $key = $self->prompt_key($msg . " (y/n?)", 'yn');
  return $key eq 'y';
}

#--------------------------------------------------------------------------#
# handlers
#--------------------------------------------------------------------------#

sub do_blocking {
  my ($self, $choice, $log) = @_;
  my $note = "perldelta: Unknown [blocking]\n";
  $self->add_note( $log->id, $note );
  return 1;
}

sub do_cherry {
  my ($self, $choice, $log) = @_;
  my $id = $log->short_id;
  $self->y_n("Recommend a cherry pick of '$id' to maint?") or return;
  my $cherrymaint = dirname($0) . "/cherrymaint";
  system("$^X $cherrymaint --vote $id");
  return; # false will re-prompt the same commit
}

sub do_done {
  my ($self, $choice, $log) = @_;
  my $note = $log->notes;
  $note =~ s{^(perldelta.*\[)\w+(\].*)}{$1done$2}ms;
  $self->add_note( $log->id, $note );
  return 1;
}

sub do_edit {
  my ($self, $choice, $log) = @_;
  my $old_note = $log->notes;
  my $new_note = $self->edit_text( $self->note_template( $log, $old_note) );
  $self->add_note( $log->id, $new_note );
  return 1;
}

sub do_head2 {
  my ($self, $choice, $log) = @_;
  my $section = _strip_parens($choice->{name});
  my $subject = $log->subject;
  my $body = $log->body;
  my $id = $log->short_id;

  my $template = $self->note_template( $log,
    "perldelta: $section [pending]\n\n=head2 $subject\n\n$body ($id)\n"
  );

  my $note = $self->edit_text( $template );
  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
    $self->add_note( $log->id, $note );
    return 1;
  }
  return;
}

sub do_linked_item {
  my ($self, $choice, $log) = @_;
  my $section = _strip_parens($choice->{name});
  my $subject = $log->subject;
  my $body = $log->body;
  my $id = $log->short_id;

  my $template = $self->note_template( $log,
    "perldelta: $section [pending]\n\n=head3 L<LINK>\n\n=over\n\n=item *\n\n$subject ($id)\n\n$body\n\n=back\n"
  );

  my $note = $self->edit_text($template);
  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
    $self->add_note( $log->id, $note );
    return 1;
  }
  return;
}

sub do_item {
  my ($self, $choice, $log) = @_;
  my $section = _strip_parens($choice->{name});
  my $subject = $log->subject;
  my $body = $log->body;
  my $id = $log->short_id;

  my $template = $self->note_template( $log,
    "perldelta: $section [pending]\n\n=item *\n\n$subject ($id)\n\n$body\n"
  );

  my $note = $self->edit_text($template);
  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
    $self->add_note( $log->id, $note );
    return 1;
  }
  return;
}

sub do_none {
  my ($self, $choice, $log) = @_;
  my $note = "perldelta: None [ignored]\n";
  $self->add_note( $log->id, $note );
  return 1;
}

sub do_platform {
  my ($self, $choice, $log) = @_;
  my $section = _strip_parens($choice->{name});
  my $subject = $log->subject;
  my $body = $log->body;
  my $id = $log->short_id;

  my $template = $self->note_template( $log,
    "perldelta: $section [pending]\n\n=item PLATFORM-NAME\n\n$subject ($id)\n\n$body\n"
  );

  my $note = $self->edit_text($template);
  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
    $self->add_note( $log->id, $note );
    return 1;
  }
  return;
}

sub do_quit { exit 0 }

sub do_repeat { return 0 }

sub do_skip { return 1 }

sub do_special {
  my ($self, $choice, $log) = @_;
  my $section = _strip_parens($choice->{name});
  my $subject = $log->subject;
  my $body = $log->body;
  my $id = $log->short_id;

  my $template = $self->note_template( $log, << "HERE" );
perldelta: $section [pending]

$subject

$body ($id)
HERE

  my $note = $self->edit_text( $template );
  if ( ($note ne $template) or $self->y_n("Note unchanged. Commit it?") ) {
    $self->add_note( $log->id, $note );
    return 1;
  }
  return;
}

sub do_subsection {
  my ($self, $choice, $log) = @_;
  my @choices = ( $choice->{subsection}, $self->submenu_choices );
  say "For " . _strip_parens($choice->{name}) . ":";
  return $self->dispatch( $self->prompt( @choices ), $log);
}

#--------------------------------------------------------------------------#
# define prompts
#--------------------------------------------------------------------------#

sub action_choices {
  my ($self) = @_;
  state $action_choices = [
      { name => '(+)Cherrymaint', handler => 'cherry' },
      { name => '(?)NeedHelp', handler => 'blocking' },
      { name => 'S(k)ip', handler => 'skip' },
      { name => '(Q)uit', handler => 'quit' },
  ];
  return $action_choices;
}

sub submenu_choices {
  my ($self) = @_;
  state $submenu_choices = [
      { name => '(B)ack', handler => 'repeat' },
  ];
  return $submenu_choices;
}


sub review_choices {
  my ($self) = @_;
  state $action_choices = [
      { name => '(E)dit', handler => 'edit' },
      { name => '(I)gnore', handler => 'none' },
      { name => '(D)one', handler => 'done' },
  ];
  return $action_choices;
}

sub section_choices {
  my ($self, $key) = @_;
  state $section_choices = [
    # Headline stuff that should go first
    {
      name => 'Core (E)nhancements',
      handler => 'head2',
    },
    {
      name => 'Securit(y)',
      handler => 'head2',
    },
    {
      name => '(I)ncompatible Changes',
      handler => 'head2',
    },
    {
      name => 'Dep(r)ecations',
      handler => 'head2',
    },
    {
      name => '(P)erformance Enhancements',
      handler => 'item',
    },

    # Details on things installed with Perl (for Perl developers)
    {
      name => '(M)odules and Pragmata',
      handler => 'subsection',
      subsection => [
        {
          name => '(N)ew Modules and Pragmata',
          handler => 'item',
        },
        {
          name => '(U)pdated Modules and Pragmata',
          handler => 'item',
        },
        {
          name => '(R)emoved Modules and Pragmata',
          handler => 'item',
        },
      ],
    },
    {
      name => '(D)ocumentation',
      handler => 'subsection',
      subsection => [
        {
          name => '(N)ew Documentation',
          handler => 'linked_item',
        },
        {
          name => '(C)hanges to Existing Documentation',
          handler => 'linked_item',
        },
      ],
    },
    {
      name => 'Dia(g)nostics',
      handler => 'subsection',
      subsection => [
        {
          name => '(N)ew Diagnostics',
          handler => 'item',
        },
        {
          name => '(C)hanges to Existing Diagnostics',
          handler => 'item',
        },
      ],
    },
    {
      name => '(U)tilities',
      handler => 'linked_item',
    },

    # Details on building/testing Perl (for porters and packagers)
    {
      name => '(C)onfiguration and Compilation',
      handler => 'item',
    },
    {
      name => '(T)esting', # new tests or significant notes about it
      handler => 'item',
    },
    {
      name => 'Pl(a)tform Support',
      handler => 'subsection',
      subsection => [
        {
          name => '(N)ew Platforms',
          handler => 'platform',
        },
        {
          name => '(D)iscontinued Platforms',
          handler => 'platform',
        },
        {
          name => '(P)latform-Specific Notes',
          handler => 'platform',
        },
      ],
    },

    # Details on perl internals (for porters and XS developers)
    {
      name => 'Inter(n)al Changes',
      handler => 'item',
    },

    # Bugs fixed and related stuff
    {
      name => 'Selected Bug (F)ixes',
      handler => 'item',
    },
    {
      name => 'Known Prob(l)ems',
      handler => 'item',
    },

    # dummy options for special handling
    {
      name => '(S)pecial',
      handler => 'special',
    },
    {
      name => '(*)None',
      handler => 'none',
    },
  ];
  return $section_choices;
}

sub section_order {
  my ($self) = @_;
  state @order;
  if ( ! @order ) {
    for my $c ( @{ $self->section_choices } ) {
      if ( $c->{subsection} ) {
        push @order, map { $_->{name} } @{$c->{subsection}};
      }
      else {
        push @order, $c->{name};
      }
    }
  }
  return @order;
}

#--------------------------------------------------------------------------#
# Utility functions
#--------------------------------------------------------------------------#

sub _strip_parens {
  my ($name) = @_;
  $name =~ s/[()]//g;
  return $name;
}

sub _prepend_comment {
  my ($text) = @_;
  return join ("\n", map { s/^/# /g; $_ } split "\n", $text);
}

sub _strip_comments {
  my ($text) = @_;
  return join ("\n", grep { ! /^#/ } split "\n", $text);
}

#--------------------------------------------------------------------------#
# Extend Git::Wrapper::Log
#--------------------------------------------------------------------------#

package Git::Wrapper::XLog;
BEGIN { our @ISA = qw/Git::Wrapper::Log/; }

sub subject { shift->attr->{subject} }
sub body { shift->attr->{body} }
sub short_id { shift->attr->{short_id} }

sub from_log {
  my ($class, $log) = @_;

  my $msg = $log->message;
  my ($subject, $body) = $msg =~ m{^([^\n]+)\n*(.*)}ms;
  $subject //= '';
  $body //= '';
  $body =~ s/[\r\n]*\z//ms;

  my ($short) = Git::Wrapper->new(".")->rev_parse({short => 1}, $log->id);

  $log->attr->{subject} = $subject;
  $log->attr->{body} = $body;
  $log->attr->{short_id} = $short;
  return bless $log, $class;
}

sub notes {
  my ($self) = @_;
  my @notes = eval { Git::Wrapper->new(".")->notes('show', $self->id) };
  pop @notes while @notes && $notes[-1] =~ m{^\s*$};
  return unless @notes;
  return join ("\n", @notes);
}

__END__

=head1 NAME

git-deltatool - Annotate commits for perldelta

=head1 SYNOPSIS

 # annotate commits back to last 'git describe' tag

 $ git-deltatool

 # review annotations

 $ git-deltatool --mode review

 # review commits needing help

 $ git-deltatool --mode review --type blocking

 # summarize commits needing help

 $ git-deltatool --mode summary --type blocking

 # assemble annotations by section to STDOUT

 $ git-deltatool --mode render

 # mark 'pending' annotations as 'done' (i.e. added to perldelta)

 $ git-deltatool --mode update --type pending --status done

=head1 OPTIONS

=over

=item B<--mode>|B<-m> MODE

Indicates the run mode for the program.  The default is 'assign' which
assigns categories and marks the notes as 'pending' (or 'ignored').  Other
modes are 'review', 'render', 'summarize' and 'update'.

=item B<--type>|B<-t> TYPE

Indicates what types of commits to process.  The default for 'assign' mode is
'new', which processes commits without any perldelta notes.  The default for
'review', 'summarize' and 'render' modes is 'pending'.  The options must be set
explicitly for 'update' mode.

The type 'blocking' is reserved for commits needing further review.

=item B<--status>|B<-s> STATUS

For 'update' mode only, sets a new status.  While there is no restriction,
it should be one of 'new', 'pending', 'blocking', 'ignored' or 'done'.

=item B<--since> REVISION

Defines the boundary for searching git commits.  Defaults to the last
major tag (as would be given by 'git describe').

=item B<--help>

Shows the manual.

=back

=head1 TODO

It would be nice to make some of the structured sections smarter -- e.g.
look at changed files in pod/* for Documentation section entries.  Likewise
it would be nice to collate them during the render phase -- e.g. cluster
all platform-specific things properly.

=head1 AUTHOR

David Golden <dagolden@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2010 by David Golden.

This is free software; you can redistribute it and/or modify it under the same
terms as the Perl 5 programming language system itself.

=cut