gitolite/contrib/adc/hub

471 lines
15 KiB
Perl
Executable File

#!/usr/bin/perl -w
# SECURITY: look for the word SECURITY below and decide...
# handle pull-requests and related stuff
# developer notes:
# - 'requestor' is too long, so I use "bob"; if you see the documentation
# you'll realise this isn't as crazy as you think :-)
use strict;
use warnings;
die "ENV GL_RC not set\n" unless $ENV{GL_RC};
die "ENV GL_BINDIR not set\n" unless $ENV{GL_BINDIR};
sub usage {
print STDERR <<'EOF';
GENERAL USAGE: ssh git@server hub <hub-command> <args>
See docs for concepts; this usage message is only a refresher!
Requestor's commands (repo child):
request-pull child b1 [parent]
request-status child [parent] [request-number]
Parent repo owner's commands (repo parent):
list-requests parent
view-request parent request-number
view-log parent request-number <git log options>
view-diff parent request-number <git diff options>
reject parent request-number
fetch parent request-number
accept parent request-number
EOF
exit 1;
}
our $tempdir;
END {
wrap_chdir($ENV{GL_REPO_BASE_ABS});
system("rm", "-rf", "$tempdir.git") if $tempdir and $tempdir =~ /gl-internal-temp-repo/;
}
my %dispatch = (
rp => \&rp,
'request-pull' => \&rp,
rs => \&rs,
'request-status' => \&rs,
lr => \&lr,
'list-requests' => \&lr,
vr => \&vr,
'view-request' => \&vr,
vl => \&vl,
'view-log' => \&vl,
vd => \&vd,
'view-diff' => \&vd,
reject => \&reject,
fetch => \&fetch,
accept => \&accept,
);
my $cmd = shift || '';
usage() unless ($dispatch{$cmd});
unshift @INC, $ENV{GL_BINDIR};
require gitolite or die "parse gitolite.pm failed\n";
gitolite->import;
# find what is effectively GL_ADC_PATH, then get the config var we're interested in
use FindBin;
my $BASE_FETCH_URL = `. $FindBin::Bin/adc.common-functions; echo \$BASE_FETCH_URL`;
chomp($BASE_FETCH_URL);
my $GL_FORKED_FROM = `. $FindBin::Bin/adc.common-functions; echo \$GL_FORKED_FROM`;
chomp($GL_FORKED_FROM);
my @args = @ARGV; @ARGV = ();
$dispatch{$cmd}->(@args);
# -------------------- bob's commands
sub rp {
# request-pull child b1 [parent]
usage() unless @_ == 2 or @_ == 3;
# implicitly gives owner-parent read access to part of child, so requestor
# should already have read access to child (to prevent someone gaining
# access to child by faking a pull request against it!)
# XXX would it be better to ensure it is writable by Bob, because how/why
# would he make a pull request if he didn't just write to it?
my ($repo, $creator) = readable_repo(shift);
my $ref = valid_ref($repo, shift);
# the parent is either explicitly given, or the name of the parent
# recorded by the 'fork' ADC is used
my $repo_to = shift || parent_repo($repo);
# requestor need not have any access to parent; it is quite possible he
# gets this via git-daemon or something, so we just need to make sure it's
# a valid repo
$repo_to = valid_repo($repo_to);
# the 'cover letter' message comes from STDIN
my $cover = join("", <>);
# now create/update the pull request file
cd2repo($repo_to);
my %hub = get_hub();
$hub{$repo}{$ref}{BOB} = $ENV{GL_USER};
$hub{$repo}{$ref}{COVER} = $cover;
$hub{$repo}{$ref}{TIME} = time();
$hub{$repo}{$ref}{STATUS} = 'pending';
dump_hub(%hub);
}
sub rs {
# request-status child [parent] [request-number]
usage() unless @_ > 0 and @_ < 4; # 1 or 2 or 3
# same checks as in 'rp' above
my ($repo_from, $creator) = readable_repo(shift);
my $repo;
if ($_[0] and $_[0] !~ /^\d+$/) {
# next arg is not a number, so it should be 'parent'
$repo = shift;
} else {
$repo = parent_repo($repo_from);
}
$repo = valid_repo($repo);
my $rqno = 0;
$rqno = shift if ($_[0] and $_[0] =~ /^\d+$/);
# there should not be any arguments left over
usage() if @_;
unless ($rqno) {
cd2repo($repo);
my %hub_full = get_hub();
return unless $hub_full{$repo_from};
my %hub; $hub{$repo_from} = $hub_full{$repo_from};
list_hub('', %hub);
return;
}
my ($child, $ref, %hub) = get_request_N($repo, $rqno);
# this also does a chdir to $repo, by the way
my %hub1; $hub1{$child}{$ref} = $hub{$child}{$ref};
list_hub('', %hub1);
print "\nMessage:\n$hub1{$child}{$ref}{COVER}\n";
}
# -------------------- alice's commands
sub lr {
# list-requests parent [optional search strings]
usage() unless @_ >= 1;
# Alice must have write access to parent, otherwise she can't really
# accept a pull request eventually right?
my ($repo, $creator) = writable_repo(shift);
cd2repo($repo);
my %hub = get_hub();
return unless %hub;
# create the search pattern. ADC arg checking is very strict; it doesn't
# allow &, | etc., so we just generate an OR condition out of the pieces
my $patt = join("|", @_);
list_hub($patt, %hub);
}
sub vr {
# view-request parent request-number
usage() unless @_ == 2;
my ($repo, $n) = @_;
my ($child, $ref, %hub) = get_request_N($repo, $n);
# this also does a chdir to $repo, by the way
my %hub1; $hub1{$child}{$ref} = $hub{$child}{$ref};
list_hub('', %hub1);
print "\nMessage:\n$hub1{$child}{$ref}{COVER}\n";
}
sub vl {
# view-log parent request-number <git log options>
usage() unless @_ >= 2;
my ($repo, $n) = (shift, shift);
my ($child, $ref, %hub) = get_request_N($repo, $n);
# so now we can find the set of SHAs that we already have
# XXX should we include tags also?
my @known_shas = grep { chomp; } `git for-each-ref refs/heads --format='%(objectname)'`;
# make a copy of the child repo (Bob's repo) containing only the ref being
# offered for fetch, then cd to it. This is easier to do than to sanitise
# all possible git-log arguments. We're doing this to prevent Alice from
# seeing anything more than the ref offered.
temp_clone($child, $ref);
# verify the list of "known_shas" because what's known in Alice's repo may
# not be known here. While you're about it, negate them. (We don't want
# to use "--not" because we're not sure what arguments the user will want
# to add and we don't want to negate some of them by mistake
@known_shas = grep { $_ = `git rev-parse --verify -q $_`; chomp && s/^/^/ } @known_shas;
# run the log command
# XXX SECURITY XXX do we need to check these arguments? Don't forget they
# are restricted by $ADC_CMD_ARGS_PATT (defined in gitolite_rc.pm), which
# is pretty tight to start with, so we know this cannot be used to run
# external programs. The question is, are any of git-log's arguments
# dangerous in their own right?
my @args = ('git', 'log', $ref);
push @args, @known_shas if @known_shas;
check_SHAs($ref, @_);
# each SHA in @_ must be a parent of $ref. Non-shas are not allowed
# since all refs other than $ref have been deleted in the temp clone
push @args, @_ if @_;
system @args;
}
sub vd {
# view-diff parent request-number <git diff options>
usage() unless @_ >= 4;
# we just check for 4 arguments; I guess later on we could also check
# to make sure at least 2 of them are SHAs or something but unless
# there's a security risk it's not needed
my ($repo, $n) = (shift, shift);
my ($child, $ref, %hub) = get_request_N($repo, $n);
# this also does a chdir to $repo, by the way
# now go to the child repo (Bob's repo)
temp_clone($child, $ref);
# run the diff command
# XXX SECURITY XXX do we need to check these arguments? Don't forget they
# are restricted by $ADC_CMD_ARGS_PATT (defined in gitolite_rc.pm), which
# is pretty tight to start with, so we know this cannot be used to run
# external programs. The question is, are any of git-diff's arguments
# dangerous in their own right?
my @args = ('git', 'diff');
check_SHAs($ref, @_);
push @args, @_ if @_;
system @args;
}
sub reject {
# reject parent request-number
usage() unless @_ == 2;
my ($repo, $n) = @_;
writable_repo($repo); # yeah we're throwing away the return values
my ($child, $ref, %hub) = get_request_N($repo, $n);
map { die "request status is already '$_'\n" if $_ ne 'pending' } $hub{$child}{$ref}{STATUS};
# the 'cover letter' message comes from STDIN
my $cover = join("", <>);
$hub{$child}{$ref}{STATUS} = "rejected by $ENV{GL_USER}";
$hub{$child}{$ref}{COVER} .= "\n\nRejected. Message to requestor:\n$cover";
dump_hub(%hub);
}
sub fetch {
# fetch parent request-number
usage() unless @_ == 2;
my ($repo, $n) = @_;
writable_repo($repo); # yeah we're throwing away the return values
my ($child, $ref, %hub) = get_request_N($repo, $n);
map { die "request status is already '$_'\n" if $_ ne 'pending' } $hub{$child}{$ref}{STATUS};
print "user $hub{$child}{$ref}{BOB} asked you to\n\tgit fetch $BASE_FETCH_URL/$child $ref\n";
print "hit enter to accept the fetch request or Ctrl-C to cancel...";
<>;
my $fetched_ref = "refs/heads/requests/child/$ref";
# you're already chdir'd to parent, by get_request_N
system("git", "update-ref", "-d", "refs/heads/$fetched_ref");
system("git", "fetch", "$ENV{GL_REPO_BASE_ABS}/$child.git", "$ref:$fetched_ref");
$hub{$child}{$ref}{STATUS} = "fetched by $ENV{GL_USER}";
dump_hub(%hub);
}
sub accept {
# accept parent request-number
usage() unless @_ == 2;
my ($repo, $n) = @_;
writable_repo($repo); # yeah we're throwing away the return values
my ($child, $ref, %hub) = get_request_N($repo, $n);
map { die "request status is '$_'; must be 'fetched'\n" if $_ !~ /^fetched by / } $hub{$child}{$ref}{STATUS};
# the 'cover letter' message comes from STDIN
my $cover = join("", <>);
$hub{$child}{$ref}{STATUS} = "accepted by $ENV{GL_USER}";
$hub{$child}{$ref}{COVER} .= "\n\nAccepted. Message to requestor:\n$cover";
dump_hub(%hub);
}
# -------------------- service subs
sub assert {
my ($expr, $message) = @_;
eval $expr or die ($message ? "$message\n" : "assert '$expr' failed\n");
}
sub cd2repo {
my $repo = shift;
wrap_chdir("$ENV{GL_REPO_BASE_ABS}/$repo.git");
}
sub dump_hub {
# pwd assumed to git repo.git; dump a file called "gl-adc-hub-requests"
use Data::Dumper;
$Data::Dumper::Indent = 1;
$Data::Dumper::Sortkeys = 1;
my %hub = @_;
my $fh = wrap_open(">", "gl-adc-hub-requests");
print $fh Data::Dumper->Dump([\%hub], [qw(*hub)]);
close $fh;
}
sub get_hub {
# pwd assumed to git repo.git; "do" a file called "gl-adc-hub-requests"
return () unless -w "gl-adc-hub-requests";
our %hub = ();
do "gl-adc-hub-requests" or die "error parsing gl-adc-hub-requests\n";
return %hub;
}
sub get_request_N {
# given a repo and an N, return "child", "ref", and %hub (or die trying!)
# you can't look at pull requests for repos you don't have at least read access to
my ($repo, $creator) = readable_repo(shift);
cd2repo($repo);
my %hub = get_hub();
die "you have no pending requests\n" unless %hub;
my $n = shift || '';
usage() unless ($n =~ /^\d+$/);
my @hub = hub_sort(%hub);
die "you have only " . scalar(@hub) . " requests\n" if @hub < $n;
$n--; # make it 0-relative
return ($hub[$n]->{REPO}, $hub[$n]->{REF}, %hub);
}
sub hub_sort {
my %hub = @_;
my %sorted_hub = ();
for my $child (sort keys %hub) {
for my $ref (sort keys %{ $hub{$child} }) {
my $key = $hub{$child}{$ref}{TIME} . "-$child-$ref";
$sorted_hub{$key} = { REPO=>$child, REF=>$ref };
}
}
my @hub = ();
for my $key (sort keys %sorted_hub) {
push @hub, $sorted_hub{$key};
}
return @hub;
}
sub list_hub {
my ($status, %hub) = @_;
my $header = "#\tchild-repository-name\t(requestor)\tbranch-or-tag-to-pull\tstatus\n----\n";
my @hub = hub_sort(%hub);
my $sn = 0;
for my $pr (@hub) {
$sn++;
my $child = $pr->{REPO};
my $ref = $pr->{REF};
my $pr_status = $hub{$child}{$ref}{STATUS};
next if $status and $pr_status !~ /$status/;
print $header if $header; $header = '';
print "$sn\t$child\t($hub{$child}{$ref}{BOB})\t$ref\t$pr_status\n";
}
}
sub parent_repo {
my ($repo) = shift;
cd2repo($repo);
die "parent repo was not recorded, sorry!\n" unless -f $GL_FORKED_FROM;
my $gff = `cat $GL_FORKED_FROM`;
chomp($gff);
return $gff;
}
sub readable_repo {
my $repo = valid_repo(shift);
my ($perm, $creator) = check_access($repo);
die "$repo does not exist or you have no read access\n" unless $perm =~ /R/;
return ($repo, $creator);
}
sub valid_log_options {
}
sub valid_ref {
my ($repo, $ref) = @_;
cd2repo($repo);
die "invalid ref $ref\n" unless `git cat-file -t $ref` =~ /^commit$/;
die "invalid ref $ref\n" unless `git rev-parse $ref` =~ /^[0-9a-f]{40}$/;
return $ref;
}
sub valid_repo {
my $repo = shift;
$repo =~ s/\.git$//;
die "$repo does not exist or you have no read access\n" unless -d "$ENV{GL_REPO_BASE_ABS}/$repo.git";
return $repo;
}
sub writable_repo {
my $repo = valid_repo(shift);
my ($perm, $creator) = check_access($repo);
die "$repo does not exist or you have no write access\n" unless $perm =~ /W/;
return ($repo, $creator);
}
sub temp_clone {
my ($repo, $ref) = @_;
die "internal error; temp_clone called twice?\n" if $tempdir;
# some of this code is also in "rrr" branch
# first make a temp directory within $REPO_BASE
$ENV{TMPDIR} = $ENV{GL_REPO_BASE_ABS};
$tempdir = `mktemp -d -t gl-internal-temp-repo.XXXXXXXXXX`;
chomp($tempdir);
rename $tempdir, "$tempdir.git";
# make the clone
wrap_chdir("$ENV{GL_REPO_BASE_ABS}");
system("git clone --mirror -l $repo.git $tempdir.git >/dev/null 2>&1");
# go to the clone and delete refs he's not allowed to read
wrap_chdir("$ENV{GL_REPO_BASE_ABS}");
wrap_chdir("$tempdir.git");
# for each available ref
for my $ar (`git for-each-ref refs '--format=%(refname)'`) {
chomp($ar);
system('git', 'update-ref', '-d', $ar) unless $ar eq "refs/heads/$ref";
}
# you've already cd-d to the temp repo, just set the name up properly
$tempdir =~ s/^\Q$ENV{GL_REPO_BASE_ABS}\///;
}
sub check_SHAs {
my $ref = shift;
for (@_) {
next unless /^[0-9a-f]+$/i;
my $fullsha = `git rev-parse $_`;
chomp($fullsha);
die "invalid SHA: $_\n" unless $fullsha =~ /^[0-9a-f]{40}$/;
my $mergebase = `git merge-base $fullsha $ref`;
chomp($mergebase);
die "invalid SHA: $_\n" unless $mergebase eq $fullsha;
}
}