Revision control
++ #! /usr/bin/perl
++
++ # Copyright (C) 2015 The Qt Company Ltd.
++ # Contact: http://www.qt.io/licensing/
++ #
++ # You may use this file under the terms of the 3-clause BSD license.
++ # See the file LICENSE from this package for details.
++ #
++
++ use strict;
++ use warnings;
++ use POSIX;
++ use JSON;
++ use File::Path;
++ use Gerrit::REST;
++
++ # Usage: $0 [instance]
++ # - default instance is 'sanitybot'
++ # - configure ssh: Host, Port, User, IdentityFile
++ # - configure git: git config --global <instance>}.<option> <value>
++ # Valid options are:
++ # gerrithost (mandatory)
++ # Target host. The identification is done via SSH.
++ # gerritactorhost
++ # Target host for inviting reviewers. Falls back go gerrithost.
++ # resthost (mandatory)
++ # The REST base URL of the target host.
++ # restuser
++ # The user name for resthost. If omitted, credentials are expected in .netrc.
++ # restpass
++ # The password for resthost. If omitted, credentials are expected in .netrc.
++ # useremail (mandatory)
++ # Bot's email address. Used to identify invitations and own actions.
++ # inviteonly (default 0)
++ # If this flag is set, the bot will only become active if it is a
++ # requested reviewer. DON'T USE (see TODO).
++ # gitbasedir (mandatory)
++ # Base dir for local GIT clones of the projects.
++ # gitdofetch
++ # Need to fetch the repos or are they local?
++ # workers
++ # A space-separated list of workers.
++ # They are run in a local bare clone of the inspected repository.
++ # There must be a workers.<worker> config entry for each worker
++ # (note that these entries are NOT namespaced with the instance).
++ # The magic string @SHA1@ is replaced by the commit to be checked;
++ # @PROJ@ by the gerrit project the worker is run for.
++ # @BRANCH@ is the branch the change was submitted to.
++ # It is expected to dump a Gerrit ReviewInput JSON entity to stdout.
++ # The output from all workers is merged.
++ # excluded (optional)
++ # Space-separated list of exclusions of the form <project>[:<branch>].
++ # To globally exclude a branch, use *:<branch>.
++ # maintainers (optional)
++ # Space-separated list of reviewers to add on "special occasions".
++ # verbose (default 0)
++ # Print progress/result messages.
++
++ # TODO
++ # - Implement some retry mechanism to deal with network failures
++ # - Make inviteonly actually work beyond the initial startup.
++
++ my $instance = 'sanitybot';
++ $instance = $ARGV[0] if ($#ARGV > -1);
++
++ # Doing this is less expensive than calling git repeatedly.
++ my %config = ();
++ for (`git config -l`) {
++ /^([^=]+)=(.*$)/;
++ $config{$1} = $2;
++ }
++
++ sub getcfg($;$)
++ {
++ my ($key, $def) = @_;
++ my $fkey = $instance.'.'.$key;
++ if (defined $config{$fkey}) {
++ return $config{$fkey};
++ } elsif (@_ > 1) {
++ return $def;
++ } else {
++ die $fkey." not set.\n";
++ }
++ }
++
++ my $GERRIT_HOST = getcfg 'gerrithost';
++ my $GERRIT_ACTOR_HOST = getcfg 'gerritactorhost', $GERRIT_HOST;
++ my $USER_EMAIL = getcfg 'useremail';
++ my $INVITE_ONLY = getcfg 'inviteonly', 0;
++ my $REST_HOST = getcfg 'resthost';
++ my $REST_USER = getcfg 'restuser', undef;
++ my $REST_PASS = getcfg 'restpass', undef;
++ my $GIT_BASEDIR = getcfg 'gitbasedir';
++ my $GIT_DO_FETCH = getcfg 'gitdofetch';
++ my $WORKERS = getcfg 'workers';
++ my %EXCLUDED_PROJECTS = map { $_ => 1 } split(/\s+/, getcfg('excluded', ""));
++ my @MAINTAINERS = split(/\s+/, getcfg('maintainers', ""));
++ my $verbose = getcfg 'verbose', 0;
++
++ my @gerrit = ("ssh", $GERRIT_HOST, "gerrit");
++ my @gerrit_actor = ("ssh", $GERRIT_ACTOR_HOST, "gerrit");
++ my $gerrit_rest = Gerrit::REST->new($REST_HOST, $REST_USER, $REST_PASS);
++
++ my %processed = ();
++ my %skipfetch = ();
++
++ sub printerr($)
++ {
++ my ($msg) = @_;
++ die $msg.": execution failed: ".$!."\n" if ($? < 0);
++ die $msg.": command crashed with signal ".$?."\n" if ($? & 127);
++ print STDERR $msg.".\n";
++ }
++
++ sub merge_hashes($$);
++
++ sub merge_hashes($$)
++ {
++ my ($base, $ext) = @_;
++
++ for my $k (keys %$ext) {
++ my $br = \$$base{$k};
++ my $ev = $$ext{$k};
++ if (!defined($$br)) {
++ $$br = $ev;
++ } else {
++ my $bt = ref($$br);
++ my $et = ref($ev);
++ die "Values for '$k' have different types ($bt, $et).\n" if ($bt ne $et);
++ if ($et eq "HASH") {
++ merge_hashes($$br, $ev);
++ } elsif ($et eq "ARRAY") {
++ push @$$br, @$ev;
++ } elsif ($et eq "") {
++ $$br = $$br."\n\n".$ev;
++ } else {
++ die "Values for '$k' have unsupported type $bt.\n";
++ }
++ }
++ }
++ }
++
++ sub process_commit($$$$$)
++ {
++ my ($number, $project, $branch, $ref, $rev) = @_;
++
++ if (defined $processed{$ref}) {
++ return;
++ }
++ $processed{$ref} = 1;
++ my $orig_project = $project;
++ $project =~ s,/$,,; # XXX Workaround QTQAINFRA-381
++ my $verdict = {};
++ my @invite;
++ if (defined($EXCLUDED_PROJECTS{$project})
++ || defined($EXCLUDED_PROJECTS{"*:".$branch})
++ || defined($EXCLUDED_PROJECTS{$project.":".$branch})) {
++ $verbose and print "===== ".strftime("%c", localtime(time()))." ===== excluding commit ".$ref." in ".$project." on branch ".$branch."\n";
++ $$verdict{message} = "(skipped)";
++ $$verdict{labels}{'Sanity-Review'} = 1;
++ } else {
++ $verbose and print "===== ".strftime("%c", localtime(time()))." ===== processing commit ".$ref." in ".$project."\n";
++ my $GIT_DIR = $GIT_BASEDIR."/".$project.".git";
++ if (!-d $GIT_DIR) {
++ mkpath $GIT_DIR or die "cannot create ".$GIT_DIR.": ".$!;
++ }
++ chdir $GIT_DIR or die "cannot change to ".$GIT_DIR.": ".$!;
++ if ($GIT_DO_FETCH) {
++ if (!-d $GIT_DIR."/refs/remotes" and `git config remote.origin.url` eq "") {
++ if (!-d $GIT_DIR."/refs") {
++ if (system("git", "init", "--bare")) {
++ printerr "Init of ".$project." failed";
++ return;
++ }
++ }
++ if (system("git", "remote", "add", "origin", 'ssh://'.$GERRIT_HOST.'/'.$project)) {
++ printerr "Adding remote for ".$project." failed";
++ return;
++ }
++ }
++ my @mainlines;
++ if (!defined $skipfetch{$project}) {
++ # Update refs, otherwise the selective fetches start from scratch each time.
++ chomp(@mainlines = `git config remote.origin.fetch`);
++ $skipfetch{$project} = 1;
++ }
++ if (system("git", "fetch", "-f", "--prune", "origin", $ref.":refs/changes/".$number, @mainlines)) {
++ printerr "GIT fetch of ".$ref." from ".$project." failed";
++ return;
++ }
++ $verbose and print "===== ".strftime("%c", localtime(time()))." ===== fetched change\n";
++ }
++
++ for my $w (split(/ /, $WORKERS)) {
++ my $worker = $config{'workers.'.$w};
++ die "workers.$worker not set.\n" if (!defined($worker));
++ $worker =~ s/\@PROJ\@/$project/g;
++ $worker =~ s/\@SHA1\@/$rev/g;
++ $worker =~ s/\@BRANCH\@/$branch/g;
++ my $output;
++ open VERDICT, $worker." 2>&1 |" or die "cannot run worker '$w': ".$!;
++ {
++ local $/;
++ $output = <VERDICT>;
++ }
++ close VERDICT;
++ while ($output =~ s/^([^\{][^\n]*\n)//s) {
++ print STDERR "Non-JSON output from worker '$w': $1\n";
++ }
++ die "Worker '$w' for commit ".$ref." in ".$project." crashed with signal ".$?.".\n" if ($? & 127);
++ die "Worker '$w' returned non-zero exit code for commit ".$ref." in ".$project.".\n" if ($?);
++ my $vd = {};
++ if (length($output) > 50000) {
++ $$vd{message} = "**** Worker '$w' produced an unreasonable amount of output. You should ask the bot maintainers for advice.";
++ push @invite, @MAINTAINERS;
++ } else {
++ $vd = decode_json($output);
++ defined($vd) or die "cannot decode verdict from worker '$w' as JSON\n";
++ if (defined($$vd{invite})) {
++ push @invite, @{$$vd{invite}};
++ delete $$vd{invite};
++ }
++ }
++ merge_hashes($verdict, $vd);
++ }
++ }
++ $$verdict{tag} = 'autogenerated:sanity';
++ my $hasComments = defined $$verdict{comments} && %{$$verdict{comments}};
++ if (defined $$verdict{labels}{'Sanity-Review'}
++ && $$verdict{labels}{'Sanity-Review'} == 1 && !$hasComments) {
++ # Don't spam everyone about a boring +1 sanity review with no comments.
++ $$verdict{notify} = "NONE";
++ }
++ if (@invite) {
++ if (system(@gerrit_actor, "set-reviewers", (map { ('-a', $_) } @invite), '-p', $project, '--', $rev)) {
++ print "===== ".strftime("%c", localtime(time()))." ===== invitation FAILED\n";
++ printerr("Inviting reviewers to ".$rev." (".$project."/".$ref.") failed");
++ } else {
++ $verbose and print "Invited @invite to ".$rev." (".$project."/".$ref.")\n";
++ }
++ }
++ eval {
++ $gerrit_rest->POST("/changes/$number/revisions/$rev/review", $verdict);
++ };
++ if ($@) {
++ print "===== ".strftime("%c", localtime(time()))." ===== verdict NOT submitted\n";
++ print STDERR "Submission of REST verdict for ".$rev." (".$project."/".$ref.") failed: $@\n";
++ return;
++ }
++ $verbose and print "Submitted verdict for ".$rev." (".$project."/".$ref.")\n";
++ }
++
++ sub do_move($$$)
++ {
++ return "Please use the menu in the top right corner 'Move change' instead of this bot.";
++ }
++
++ sub process_move($$$) {
++ my ($chg, $author, $target) = @_;
++ my $number = $$chg{'number'};
++ $gerrit_rest->POST("/changes/$number/revisions/current/review",
++ { message => do_move($chg, $author, $target) });
++ }
++
++ $| = 1; # make STDOUT autoflush
++
++ open UPDATES, "-|", @gerrit, "stream-events" or die "cannot run ssh: ".$!;
++
++ # Try to ensure that the event streaming has started before we make the snapshot, to avoid a race.
++ # Of course, the first connect may be still delayed ...
++ sleep(1);
++
++ my @query = ("status:open");
++ push @query, "reviewer:".$USER_EMAIL if ($INVITE_ONLY);
++ open STATUS, "-|", @gerrit, "query", "--format", "JSON", "--current-patch-set", @query or die "cannot run ssh: ".$!;
++ REVIEW: while (<STATUS>) {
++ my $review = decode_json($_);
++ defined($review) or die "cannot decode JSON string '".chomp($_)."'\n";
++ my $number = $$review{'number'};
++ my $project = $$review{'project'};
++ my $branch = $$review{'branch'};
++ my $cps = $$review{'currentPatchSet'};
++ if (defined $cps) {
++ my $status = $$review{'status'};
++ if ($status ne 'NEW' and $status ne 'DRAFT') {
++ next REVIEW;
++ }
++ my $ref = $$cps{'ref'};
++ my $revision = $$cps{'revision'};
++ my $approvals = $$cps{'approvals'};
++ if (defined $approvals) {
++ foreach my $appr (@$approvals) {
++ my $by = $$appr{'by'};
++ defined $$by{'email'} or next; # The reviewer may be gone and thus have no valid mail any more.
++ if ($$by{'email'} eq $USER_EMAIL) {
++ next REVIEW;
++ }
++ }
++ }
++ process_commit($number, $project, $branch, $ref, $revision);
++ }
++ }
++ close STATUS;
++
++ while (<UPDATES>) {
++ my $update = decode_json($_);
++ defined($update) or die "cannot decode JSON string '".chomp($_)."'\n";
++ my $type = $$update{'type'};
++ if (defined($type)) {
++ if ($type eq 'patchset-created') {
++ my $chg = $$update{'change'};
++ my $ps = $$update{'patchSet'};
++ process_commit($$chg{'number'}, $$chg{'project'}, $$chg{'branch'}, $$ps{'ref'}, $$ps{'revision'});
++ } elsif ($type eq 'ref-updated') {
++ my $rup = $$update{'refUpdate'};
++ delete $skipfetch{$$rup{'project'}};
++ } elsif ($type eq 'comment-added') {
++ my $cmd = $$update{'comment'};
++ if ($cmd =~ /^(?:gerrit-)?bot:\h*(?:please\h+)?move\h+(?:back\h+)?to\h+(?:branch\h+)?([\w.\/-]*\w)\b/im) {
++ process_move($$update{'change'}, $$update{'author'}, $1);
++ }
++ }
++ }
++ }
++ close UPDATES;