#!/usr/bin/perl

use strict;
use warnings;

#    Copyright 2012 Grant Street Group, All Rights Reserved.
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

# PODNAME: gitc-submit
# ABSTRACT: Submit a changeset for code review
our $VERSION = '0.60'; # VERSION

use App::Gitc::Util qw(
    branch_basis
    branch_point
    confirm
    current_branch
    fetch_tags
    full_changeset_name
    guarantee_a_clean_working_directory
    get_user_name
    get_user_email
    git
    git_config
    git_fetch_and_clean_up
    is_auto_fetch
    is_valid_ref
    its_for_changeset
    let_user_edit
    meta_data_add
    meta_data_rm
    project_config
    project_name
    user_lookup_class
);
use App::Gitc::Reversible;
use Getopt::Long;
use List::MoreUtils qw( any );

my $self_review = 0;
my $keep = 0;
my $skip_email = 0;
my $skip_edit = 0;
my $auto_rebase = 1;
GetOptions(
    'keep'       => \$keep,
    'skip-email' => \$skip_email,
    'skip-edit'  => \$skip_edit,
    'skip-auto-rebase' => sub { $auto_rebase = 0 },
);

my $reviewer = find_reviewer( shift @ARGV );
my $changeset = current_branch();
die   "You may not submit $changeset for review.\n"
    . "Perhaps you should checkout a changeset branch first.\n"
    if $changeset =~ m/^(master|test|stage|prod)$/;

# make sure the local repository is up to date
git_fetch_and_clean_up() if is_auto_fetch();

# the changeset has to have changes before submitting
my $branch_point = branch_point( full_changeset_name($changeset) );
my @commit_ids   = git "rev-list HEAD ^$branch_point";
die "You haven't committed anything to this changeset yet!\n"
    if not @commit_ids;

# should the changeset be rebased?
launch_auto_rebase($branch_point) if $auto_rebase;

my $its = its_for_changeset($changeset);

my $stash;
reversibly {
    failure_warning "\nCanceling gitc submit\n";

    $stash = guarantee_a_clean_working_directory();
    to_undo { git "stash apply $stash" if $stash; $stash = undef };

    if ( my @conflicts = find_merge_conflicts($changeset) ) {
        my $conflicts = join ', ', @conflicts;
        warn  "\nThis changeset will have merge conflicts when promoted\n"
            . "to $conflicts.  In your submit email, please provide the\n"
            . "reviewer with any instructions he might need to correctly\n"
            . "resolve the conflicts.\n"
            . "Press ENTER to continue.\n";
            ;
        my $junk = <STDIN>;
    }

    # export the patch emails and let the user edit the cover
    my ( $tmpdir, $cover_letter ) = export_patches($changeset);
    let_user_edit($cover_letter) unless ($skip_email || $skip_edit);

    # die if the user deleted the entire cover letter
    die "Aborting submit at the user's request\n" if -s $cover_letter <= 10;
    update_email_headers( $tmpdir, $cover_letter );


    # record the submit action in meta data
    my $id = meta_data_add({
        action    => 'submit',
        changeset => $changeset,
        reviewer  => $reviewer,
    });
    to_undo { meta_data_rm(id => $id, changeset => $changeset) };

    # record the review action for self-review
    if ( $self_review ) {
        my $id = meta_data_add({
            action    => 'review',
            changeset => $changeset,
        });
        to_undo { meta_data_rm(id => $id, changeset => $changeset) };
    }

    # put the changeset branch where others can see it
    if ( not $self_review ) {
        git "push --force origin $changeset:pu/$changeset";
        to_undo { git "push origin :pu/$changeset" };
    }

    # delete the local branch
    if ( not $keep ) {
        git "checkout master";
        to_undo { git "checkout -f $changeset" };
        local $@;
        eval { # failing in here is ok
            my $sha1 = is_valid_ref($changeset);
            git "branch -D $changeset";
            to_undo { git "branch $changeset $sha1" };
        };
        warn "Unable to delete branch $changeset: $@\n" if $@;
    }

    # blast out the emails
    git "send-email --to " . get_user_email($reviewer)
      . '           --from "' . author_email() . '"'
      . '           --no-chain-reply-to'
      . '           --signed-off-by-cc'
      . '           --suppress-cc author'
      . '           --suppress-from'
      . '           --quiet'
      . '           --no-validate'
      . "           $tmpdir/*.patch"
      if not $skip_email
      ;

    if ($its) {
        # update the Issue status
        my $issue = $its->get_issue($changeset, reload => 1);
        my $project = project_name();
        my $what_happened = $its->transition_state({
            command   => 'submit',
            issue     => $issue,
            reviewer  => $reviewer,
            message   => "Submitted $project#$changeset to $reviewer for code review",
            changeset => $changeset,
        });
    }
    return;
};

# reinstate any changes present when we started
git "stash apply $stash" if $stash;
exec "gitc pass --from-self-review" if $self_review;
exit;

################################ helper subs ########################

# decide who should review this changeset
sub find_reviewer {
    my $reviewer = shift or die "You must specify a reviewer\n";

    # handle self-review for dataload projects
    my $current_user = get_user_name();
    if ( $reviewer eq $current_user ) {
        if ( project_config()->{'self submit'} ) {
            $self_review = 1;
            $skip_email  = 1;
            $keep        = 1;
            return $reviewer;
        }
    }

    validate_reviewer($reviewer);
    return $reviewer;
}

# determine whether the given reviewer is valid.  if not, suggest
# an alternative based on possible mis-spellings
sub validate_reviewer {
    my ($reviewer) = @_;
   
    fetch_tags(); 
    my @users = user_lookup_class()->users();

    return if any { $_ eq $reviewer } @users;

    # an invalid reviewer.  make some suggestions
    require Text::Levenshtein;
    require List::Util;
    my @suggestions =
        map  { $_->[0] }
        sort { $a->[1] <=> $b->[1] }
        grep { $_->[1] < 4 }           # only "close" matches
        map  { [ $_, scalar Text::Levenshtein::distance( $reviewer, $_ ) ] }
        @users;
    my @short_list = @suggestions[ 0 .. List::Util::min($#suggestions, 2) ];

    my $msg = "The user name '$reviewer' is invalid. ";
    if (@suggestions) {
        $msg   .= "Perhaps you meant one of:\n";
        $msg   .= "  - $_\n" for @short_list;
    }
    else {
        $msg .= "\n";
    }
    die $msg;
}

# Execute a rebase (and never return) if this changeset hasn't accounted
# for the most recent commits on its 'onto' branch.
sub launch_auto_rebase {
    my ($branch_point) = @_;

    # are there new commits since the changeset branch?
    my $basis = branch_basis($branch_point);
    return if $basis !~ /^(master|test|stage|prod)$/;
    my @upstream = git "rev-list --first-parent origin/$basis ^HEAD";
    return if not @upstream;

    # yup, so start a rebase
    my $count = @upstream;
    my $s = $count == 1 ? '' : 's';
    warn "Uh oh, $basis has $count commit$s since you started.\n"
        . "I'm rebasing for you.  When it's done, resubmit.\n"
        . "\n"
        ;
    exec "git rebase --onto origin/$basis $branch_point";
}

sub export_patches {
    my ($changeset) = @_;

    # generate the patches
    require File::Temp;
    my $tmpdir = File::Temp::tempdir(
        'gitc-submit-XXXXX',
        TMPDIR  => 1,
        CLEANUP => 1,
    );
    my $project = project_name();
    git "format-patch -o $tmpdir"
      . "             --thread"
      . "             --no-numbered"
      . "             --cover-letter"
      . "             --no-color"
      . "             --no-binary"
      . "             -M -C --no-ext-diff"
      . "             --no-prefix"
      . "             --subject-prefix='$project#$changeset'"
      . "             " . branch_point($changeset)
      ;

    # CONFIGURE (optional)
    # Add any local custom headers to the call above 
    # TODO This should be pulled in from a configuration file

    # adjust the cover-letter subject line
    my @patches = glob("$tmpdir/*.patch");
    if ( @patches > 2 and $its ) {
        fill_in_subject_line($tmpdir, $changeset);
        return ( $tmpdir, "$tmpdir/0000-cover-letter.patch" );
    }

    # there's only one real patch, so send it
    unlink "$tmpdir/0000-cover-letter.patch";
    my ($patch) = glob("$tmpdir/*.patch");
    if ($its) {
        my $uri = $its->issue_changeset_uri( $its->get_issue($changeset) );
        if ($uri) {
            my $content = do {
                open my $fh, '<', $patch or die "Couldn't open $patch: $!";
                local $/;
                <$fh>;
            };
            $content =~ s{\n\n}{\n\n$uri\n\n};
            open my $fh, '>', $patch or die "Couldn't write to $patch: $!";
            print $fh $content;
        }
    }
    return ( $tmpdir, $patch );
}

sub fill_in_subject_line {
    my ($tmpdir, $changeset) = @_;
    my $file = "$tmpdir/0000-cover-letter.patch";

    # read in the current cover letter
    open my $fh, '<', $file or die "Couldn't read cover letter : $?";
    my $content = do { local $/; <$fh> };
    close $fh;

    # replace the default subject line
    my $its_name = $its->label_service;
    my $its_label = $its->label_issue;
    my $issue = $its->get_issue($changeset);
    my $subject = eval {
        print STDERR "Looking for $its_name $its_label...";
        my $summary = $its->issue_summary($issue);
        print STDERR "done\n";
        return $summary;
    } || "Submitted for Review";
    warn "Problem obtaining the $its_label summary: $@" if $issue and $@;
    $content =~ s/\Q*** SUBJECT HERE ***\E/$subject/;

    # remove the default blurb line
    my $uri = $its->issue_changeset_uri($issue);
    $content =~ s/\Q*** BLURB HERE ***\E/$uri/;

    # save the new version
    open $fh, '>', $file or die "Couldn't write cover letter : $?";
    print $fh $content;
    close $fh;

    return;
}

sub update_email_headers {
    my ( $tmpdir, $cover_letter ) = @_;
    return if $cover_letter !~ m/0000-cover-letter/;
    require Email::Simple;

    # find headers we want to set
    my @blacklist = qw(
        date
        from
        in-reply-to
        message-id
        references
        subject
    );
    my $cover = Email::Simple->new( slurp($cover_letter) );
    my %extra_headers;
    for my $header ( $cover->header_names ) {
        next if $header =~ m/^from /i;
        $extra_headers{ lc $header } = $cover->header($header);
    }
    delete @extra_headers{@blacklist};

    # process each patch email
    for my $file ( glob "$tmpdir/*.patch" ) {
        next if $file =~ m/0000-cover-letter/;
        my $email = Email::Simple->new( slurp($file) );
        while ( my ( $name, $value ) = each %extra_headers ) {
            $email->header_set( $name, $value );
        }

        open my $fh, '>', $file or die "Unable to write to $file: $!";
        print $fh $email->as_string;
    }

    return;
}

# this might be worth factoring out to App::Gitc::Util at some point
sub author_email {
    my $name       = get_user_name();
    my $email      = get_user_email();
    return "$name <$email>";
}

# Returns a list of environments that will have merge conflicts if
# this changeset is promoted.
sub find_merge_conflicts {
    my ($changeset) = @_;

    warn "Looking for merge conflicts...\n";
    git "checkout -q --no-track -b test-merges origin/master";
    my @conflicts;
    for my $environment (qw( master )) {
        git "reset -q --hard origin/$environment";
        my $output = git "merge --quiet --no-stat --no-ff $changeset";
        push @conflicts, $environment if $output =~ m/Automatic merge failed/;
    }

    git "reset --hard";  # clean up after any failed merges
    git "checkout -q -f $changeset";
    my $output = git "branch -D test-merges"; # there is no --quiet option
    return @conflicts;
}

# read the entire contents of a file into memory
sub slurp {
    my ($filename) = @_;
    open my $fh, '<', $filename
        or die "Unable to open $filename: $!";
    my $content = do { local $/; <$fh> };
    return $content;
}

__END__

=pod

=head1 NAME

gitc-submit - Submit a changeset for code review

=head1 VERSION

version 0.60

=head1 AUTHOR

Grant Street Group <developers@grantstreet.com>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2013 by Grant Street Group.

This is free software, licensed under:

  The GNU Affero General Public License, Version 3, November 2007

=cut
