#!/usr/bin/perl -w

# $Id$

=head1 NAME

direct-review
Быстрый просмотр изменений кода в удобном редакторе

=head1 DESCRIPTION

    Скрипт получает указанные изменения и
      - либо постит их в ReviewBoard (если указана опция --post-review)
      - либо настраивает рабочую копию в директории /tmp/direct-review-$USER, и открывает дифф
        вашим любимым редактором $SVNDIFF_EDITOR.
        После выхода из редактора, на экран выводятся пометки, оставленные в диффе.
    
    Изменения для ревью можно указывать несколькими способами:

    1. Одиночная ревизия, или диапазон через '-'
       Пример: direct-review 13000
               direct-review 13000-13200
    
    2. Номер ревью из ReviewBoard (дифф скачивается, и им патчится транк или SVN-урл, указанный вторым параметром)
       Пример: direct-review rb122
               direct-review rb122:1 # первая версия diff-а

    3. Имя файла .diff или .patch
       Пример: direct-review qwe.diff

    4. Один урл бранча SVN (базовая ревизия транка определяется автоматически)
       Пример: direct-review $DB/super-branch
    
    5. Два урла SVN
       Пример: direct-review $DT@13000 $DB/super-branch

    6. Список файлов
       Ревьюятся локальные изменения только этих файлов

    7. Без параметров ж-)
       Если текущая рабочая копия бранчевая - ревьюится бранч без локальных изменений,
       если транк - ревьюятся локальные изменения.
       Ревью проводится прямо в текущей директории, файлы автоматически не меняются.

    Дополнительные опции:

      --help - вывести справку

      --print-diff - только вывести diff в stdout

      --mark-moves - отметить в дифе перемещения текста

      --post-review - флаг, загрузать ли дифф в ReviewBoard
      --review-id - число, номер ревью в ReviewBoard, в котором нужно обновить дифф
      --submit-as=login - запостить code-review от лица другого пользователя, по умолчанию от себя

      --svn=SVN_URL - svn урл проекта
        по умолчанию: svn+ssh://svn.yandex.ru/direct
      --svn-std-layout / --no-svn-std-layout - используется ли в svn стандартная схема с /trunk /branches
        по умолчанию: true, схема используется
      --rb-server=XXX - сервер ReviewBoard для загрузки диффа
        по умолчанию: https://rb.yandex-team.ru/direct/
      --rb-user=XXX - имя пользователя ReviewBoard
      --rb-password=XXX - пароль ReviewBoard
      --rb-repository=XXX - урл svn репозитория, как он указан в ReviewBoard
        по умолчанию: svn+ssh://svn.yandex.ru/direct

      --dir=DIR - временная директория для чекаута директа
        по умолчанию: /tmp/direct-review/$USER
      --patch-subdir - на какую директорию применять патч (если патч не на весь проект)
      --patch-strip=N - опция -p для patch
        по умолчанию: 0
      --svn-diff=diff - программа, которая передастся в svn diff
      --diff-editor=$SVNDIFF_EDITOR - программа, которой пользователюб удобно просматривать дифф

=cut

use strict;
use Encode;
use File::Path;
use File::Basename;
use File::Temp;
use File::Copy;
use File::Slurp;
use Getopt::Long;
use Data::Dumper;
use List::MoreUtils;
use Pod::Usage;

use Yandex::Interactive qw/prompt_yn/;
use Yandex::HTTP;
use Yandex::Shell;
use Yandex::Svn;
use Yandex::Retry;

use ProjectSpecific qw/svn_url get_review_board_server_url/;

use utf8;

my $CURRENT_USER = [getpwuid($<)]->[0];
my $BRANCH_RE = '^((?:https|svn\+ssh):\/\/.*)\/branches\/([^\/]+)';

# Умолчательные настройки
my $SVN = svn_url();
my $RB_SERVER = get_review_board_server_url();
my $RB_REPOSITORY = $SVN;
my $RB_USER = $ENV{RB_USER};
my $RB_PASSWORD = $ENV{RB_PASSWORD};
my $DIR;
my $PATCH_SUBDIR;
my $PATCH_STRIP = 0;
my $SVN_DIFF_CMD = 'svn diff --diff-cmd=diff -x "-bBd -U 5 -F ^sub"';
my $POST_REVIEW = 0;
my $RB_TRIES = 5;
my $REVIEW_ID = 0;
my $SUBMIT_AS;
my $DIFF_EDITOR = $ENV{DIRECT_REVIEW_EDITOR} || $ENV{SVNDIFF_EDITOR} || $ENV{EDITOR} || 'emacs';
my $CONTEXT_LINES = 5;
my $SVN_STD_LAYOUT = 1;
my $PRINT_DIFF;
my $MARK_MOVES;

GetOptions(
    "help" => sub {
        system("podselect -section NAME -section DESCRIPTION $0 | pod2text-utf8 >&2");
        exit(0);
    },
    
    "print-diff" => \$PRINT_DIFF,
    "mark-moves" => \$MARK_MOVES,

    "post-review" => \$POST_REVIEW,
    "submit-as=s" => \$SUBMIT_AS,
    "review-id=i" => \$REVIEW_ID,

    "svn=s" => \$SVN,
    "svn-std-layout!" => \$SVN_STD_LAYOUT,
    "rb-server=s" => \$RB_SERVER,
    "rb-repository=s" => \$RB_REPOSITORY,

    "dir=s" => \$DIR,
    "patch-subdir=s" => \$PATCH_SUBDIR,
    "patch-strip=i" => \$PATCH_STRIP,
    "svn-diff=s" => \$SVN_DIFF_CMD,
    "diff-editor=s" => \$DIFF_EDITOR,
    ) || exit(1);

if ($REVIEW_ID && !$POST_REVIEW) {
    die "Incorrect params: review-id without post-review";
} elsif ($SUBMIT_AS && !$POST_REVIEW) {
    die "Incorrect params: submit-as without post-review";
}

# Разбираем параметры
my ($svn_from, $svn_to, $branch, $diff_name, $diff, $no_update_wc, @diff_args);
if (@ARGV == 1 && $ARGV[0] =~ /^\d+(-\d+)?$/) {
    my ($rev1, $rev2) = split /-/, $ARGV[0];
    $rev2 ||= $rev1;
    my $rev_base = $rev1-1;
    # Определяем бранч, в котором что-то изменилось
    my ($path) =
        map {/^Index: (trunk|branches\/[\w\-\.]+)/ ? $1 : ()}
        `svn diff $SVN -r $rev_base:$rev1`;
    $path ||= '/';
    $svn_from = "$SVN/$path\@$rev_base";
    $svn_to = "$SVN/$path\@$rev2";
} elsif (@ARGV == 1 && is_branch_url($ARGV[0])) {
    ($branch, $svn_from, $svn_to) = get_branch_ranges($ARGV[0]);
} elsif (@ARGV >= 1 && @ARGV <= 2 && (grep {/^(https|svn\+ssh):\/\/.*/} @ARGV) == @ARGV) {
    ($svn_from, $svn_to) = @ARGV;
    $svn_to ||= $svn_from;
} elsif ((@ARGV == 1 || @ARGV == 2) && $ARGV[0] =~ /^rb(\d+)(?::(\d+))?$/) {
    # диф из reviewdoard
    $diff_name = "rb$1".($2 ? ":$2" : '');
    my $rb_url = "$RB_SERVER/r/$1/diff".($2 ? "/$2" : '')."/raw/";
    if ($POST_REVIEW) {
        die "Can't repost review!";
    }
    $diff = http_get($rb_url) || die "Can't get $rb_url";
    $diff = Encode::decode_utf8($diff);
    $diff = prepare_rb_diff($diff);
    $svn_from = $svn_to = $ARGV[1] || ($SVN_STD_LAYOUT ? "$SVN/trunk" : $SVN);
} elsif ((@ARGV == 1 || @ARGV == 2) && $ARGV[0] =~ /\.(?:diff|patch)/ && -f $ARGV[0]) {
    $diff_name = basename($ARGV[0]);
    $diff = read_file($ARGV[0], binmode => ":utf8");
    $svn_from = $svn_to = $ARGV[1] || ($SVN_STD_LAYOUT ? "$SVN/trunk" : $SVN);
} elsif ((grep {-e} @ARGV) == @ARGV) {
    # смотрим на текущую рабочую копию
    $DIR = '.';
    $no_update_wc = 1;
    my $wc_info = svn_info($DIR);
    if (!@ARGV && is_branch_url($wc_info->{url})) {
        ($branch, $svn_from, $svn_to) = get_branch_ranges($wc_info->{url});
    } else {
        ($svn_from, $svn_to) = ($DIR, "");
        @diff_args = @ARGV ? @ARGV : ($DIR);
    }
} else {
    die "Usage: $0 rev1[-rev2]\n"
       ."    or $0 SVN_FROM SVN_TO\n"
       ."    or $0 SVN_BRANCH\n"
       ."    or $0 rbNN\n";
}
@diff_args = ($svn_from, $svn_to) if !@diff_args;

if (!defined $DIR) {
    my $svn_dir_name = URI->new($svn_from)->path;
    $svn_dir_name =~ s/\@.*//;
    $svn_dir_name =~ s/^\///;
    $svn_dir_name =~ s/[^a-z0-9.-]/_/g;
    $DIR = '/tmp/direct-review-'.[getpwuid($<)]->[0]."/$svn_dir_name";
}

my $WC_DIR;
# обновляем/перегружаем рабочую копию
if ($no_update_wc) {
    $WC_DIR = $DIR;
} else {
    # создаём директорию
    $WC_DIR = "$DIR/svnroot";
    mkpath($WC_DIR);
}

if ($PRINT_DIFF) {
    system(join " ", $SVN_DIFF_CMD, @diff_args);
    exit $?;
}

if (!$diff_name) {
    $diff_name = join("-", map {local $_=$_; s/^\Q$SVN\E\/*//; s/trunk/T/g; s/branches/B/g; s/\//_/g; s/\@//g; $_} $svn_from, $svn_to);
    $diff_name .= '.MARKED' if $MARK_MOVES;
}
my $diff_file_name = "direct-review.$diff_name.diff";
my $diff_file = "$DIR/$diff_file_name";
my $diff_file_copy = "$WC_DIR/$diff_file_name";
my $diff_file_orig = "$diff_file.orig";

my $NEED_PATCH = 0;
if (!-f $diff_file || prompt_yn("Remove old review file $diff_file? ")) {
    # выгружаем дифф
    unlink($diff_file) if -f $diff_file;
    if ($diff) {
        write_file($diff_file, {atomic => 1, binmode => ':utf8'}, $diff);
        $NEED_PATCH = 1;
    } else {
        my $cmd = join " ", $SVN_DIFF_CMD, @diff_args, ">$diff_file";
        print "Let's $cmd\n";
        system($cmd) and die "Error: $!";
    }
    copy($diff_file, $diff_file_orig) || die "Can't copy $diff_file => $diff_file.orig: $!";
}

if ($MARK_MOVES) {
    yash_system("mark_moves_in_diff $diff_file_orig >$diff_file");
}

# Если надо - посылаем в RB
if ($POST_REVIEW) {
    my @review_id_params = $REVIEW_ID ? ("-r", $REVIEW_ID) : ();
    if ($branch) {
        push @review_id_params, "--branch=$branch";
    }
    if ($SUBMIT_AS || $RB_USER && $RB_USER ne $CURRENT_USER) {
        push @review_id_params, "--submit-as=".($SUBMIT_AS||$CURRENT_USER);
    }
    # дописываем в diff к именам файлов /trunk/
    my $diff_cont = read_file($diff_file);

=head2 COMMENT
    новая версия SVN на precise дописывает в дифф 3 строки, вызывающие ошибку review-board. Удаляем

Index: .
===================================================================
--- .   (.../trunk) (revision 48850)
+++ .   (.../branches/sharding_tags)    (revision 48856)

=cut

    $diff_cont =~ s/^Index: \.\n=+\n(?:(?:---|\+\+\+) \.\s+.+\n){2}//gm;

    $diff_cont =~ s/^(---|\+\+\+) /$1 trunk\//gm;
    $diff_cont =~ s/^((?:---|\+\+\+) .*)\s*\(.*\)\s*(\(.*\))/$1 $2/gm;
    write_file("$diff_file.rb", $diff_cont);
    # делаем N попыток, слава роботам!
    retry tries => $RB_TRIES, sub {
        yash_system("rbt", "post", @review_id_params, 
                    "--diff-filename" => "$diff_file.rb", 
                    "--server" => $RB_SERVER, 
                    "--repository-url" => $RB_REPOSITORY, 
                    ($RB_USER ? ("--user" => $RB_USER) : ()),
                    ($RB_PASSWORD ? ("--password" => $RB_PASSWORD) : ()),
            );
    };
    exit 0;
}

# обновляем/перегружаем рабочую копию
if (!$no_update_wc) {
    # создаём директорию
    mkpath($WC_DIR);
    
    my @cmds;
    if (-d "$WC_DIR/.svn") {
        my_system("svn revert -R $WC_DIR");
        rmtree([svn_unversioned_files($WC_DIR)], 1);
        my_system("svn sw -q --force $svn_to $WC_DIR");
    } else {
        my_system("svn co -q $svn_to $WC_DIR");
    }

    if ($NEED_PATCH) {
        eval {
            yash_system("patch",
                        "--strip", $PATCH_STRIP,
                        "-d", $WC_DIR.(defined $PATCH_SUBDIR ? "/$PATCH_SUBDIR" : ''),
                        "-i", $diff_file
                );
        };
        if ($@) {
            print "$@\n";
            exit(1) if !prompt_yn("Patch failed. View $diff_file? ")
        }
    }
}


# Редактируем дифф
chdir($WC_DIR);
copy($diff_file, $diff_file_copy);
system($DIFF_EDITOR, $diff_file_copy) and die "Error: $!";
copy($diff_file_copy, $diff_file);

# Теперь находим различия между диффами
my ($f1, $f2) = map {yash_quote($_)} $diff_file_orig, $diff_file;
my @diff_lines = `diff --unified=1000000 $f1 $f2`;
if ($? / 256 > 1) {
    die "diff failed: $?, $!";
}

# Пропускаем заголовок
splice @diff_lines, 0, 3;
@diff_lines =
    # Удаляем удаления
    grep {!/^-/}
    # Исправляем заголовки ханков
    map {s/^\-(\@\@)/ $1/;$_} grep {!/\+(\@\@)/}
    @diff_lines;

# Находим отличия
my @files;
for my $file_text (split /\n(?=(?: Index:))/, join("", @diff_lines)) {
    if ($file_text =~ /^( Index: [^\n]*\n ===[^\n]*\n --- [^\n]*\n \+\+\+ [^\n]*)\n(.*)/s) {
        my ($head, $body) = ($1, $2);
        $head =~ s/^ //gm;
        my @hunks;
        for my $hunk_text (split /\n(?=(?: \@\@))/, $body) {
            my @hunk_lines = split /\n/, $hunk_text;
            my @changed_line_nums = grep {$hunk_lines[$_] =~ s/^(\+)/  !!REVIEW!! /} 0..$#hunk_lines;
            next if !@changed_line_nums;
            my @saved_lines = sort {$a <=> $b} grep {$_ >= 0} List::MoreUtils::uniq(0, map {($_-$CONTEXT_LINES .. $_+$CONTEXT_LINES)} @changed_line_nums);
            my @res_lines;
            for my $i (0 .. $#saved_lines) {
                push @res_lines, substr $hunk_lines[$saved_lines[$i]], 1;
                my $skipped = $i == $#saved_lines
                    ? $#hunk_lines - $saved_lines[$i] + 1
                    : $saved_lines[$i+1] - $saved_lines[$i] - 1;
                if ($skipped) {
                    push @res_lines, " ... skipped $skipped line".($skipped > 1 ? 's' : '')." ...";
                }
            }
            push @hunks, join("\n", @res_lines);
        }
        next if !@hunks;
        push @files, {head => $head, hunks => \@hunks};
    } elsif ($file_text =~ /^\s*Index:\s+(.*)\s*\n\s*=+\s*$/) {

    } elsif ($file_text =~ /^\s*Index:\s+(.*)\s*\n\s*=+\s*\n\s*Cannot display: file marked as a binary type./) {
        
    } else {
        die "parsing error: $file_text";
    }
}

my $res_diff = join "\n",
    map {join "\n", $_->{head}, @{$_->{hunks}}} @files;
print $res_diff, "\n";

if ($res_diff) {
    my $review_file = "$diff_file.review";
    write_file($review_file, {atomic => 1}, $res_diff);
    print "Review file: $review_file\n";
}

sub my_system {
    print "Let's @_\n";
    system(@_) and die "Error: $!";
}

# если в дифе указаны полные пути - оставляет относительные
sub prepare_rb_diff {
    my ($diff) = @_;
    $diff =~ s#
        ^(Index:\s+)(?:/?trunk/|/?branches/[^/]+/)?(.*\n)
        (=+\s*\n)
        (---\s+)(?:/?trunk/|/?branches/[^/]+/)?(.*\n)
        (\+\+\+\s+)(?:/?trunk/|/?branches/[^/]+/)?(.*\n)
        #$1$2$3$4$5$6$7#mgx;
    return $diff;
}

sub is_branch_url {
    my $url = shift;
    return scalar $url =~ /$BRANCH_RE/;
}

sub get_branch_ranges {
    my $url = shift;
    die "incorrect url" if $url !~ /$BRANCH_RE/;
    my $branch = $2;
    my $svn_to = $url;
    my $svn_from = "$1/trunk";
    my $res = `svn mergeinfo --show-revs eligible $svn_from $svn_to`;
    if ($res =~ /^r(\d+)/) {
        $svn_from .= '@'.($1-1);
    }
    return ($branch, $svn_from, $svn_to);
}
