App-GitHubPullRequest

 view release on metacpan or  search on metacpan

lib/App/GitHubPullRequest.pm  view on Meta::CPAN

#!perl

use strict;
use warnings;
use feature qw(say state);

package App::GitHubPullRequest;
$App::GitHubPullRequest::VERSION = '0.6.0';
# ABSTRACT: Command-line tool to query GitHub pull requests

use JSON qw(decode_json encode_json);
use Carp qw(croak);
use Encode qw(encode_utf8);
use File::Spec;

use constant DEBUG => $ENV{'GIT_PR_DEBUG'} || 0;


sub new {
    my ($class) = @_;
    return bless {}, $class;
}


sub run {
    my ($self, @args) = @_;
    my $cmd = scalar @args ? shift @args : 'list';
    my $method = $self->can($cmd);
    return $self->$method(@args) if $method;
    return $self->help(@args);
}


sub help {
    my ($self, @args) = @_;
    print <<"EOM";
$0 [<command> <args> ...]

Where command is one of these:

  help              Show this page
  list [<state>]    Show all pull requests (state: open/closed)
  show <number>     Show details for the specified pull request
  patch <number>    Fetch a properly formatted patch for specified pull request
  checkout <number> Create tracking branch for specified pull request

  login [<user>] [<password>] [<two-factor-auth-token>]
                              Login to GitHub and receive an access token (deprecated)
  authorize                   Create a GitHub OAuth personal access token
  comment <number> [<text>]   Create a comment on the specified pull request
  close <number>              Close the specified pull request
  open <number>               Reopen the specified pull request

EOM
    return 1;
}


sub list {
    my ($self, $state) = @_;
    $state ||= 'open';
    my $remote_repo = _find_github_remote();
    my $prs = _api_read("/repos/$remote_repo/pulls?state=$state");
    say ucfirst($state) . " pull requests for '$remote_repo':";
    unless ( @$prs ) {
        say "No pull requests found.";
        return 0;
    }
    foreach my $pr ( @$prs ) {
        my $number = $pr->{"number"};
        my $title = encode_utf8( $pr->{"title"} );
        my $date = $pr->{"updated_at"} || $pr->{'created_at'};
        say join(" ", $number, $date, $title);
    }
    return 0;
}


sub show {
    my ($self, $number, @args) = @_;
    die("Please specify a pull request number.\n") unless $number;
    my $pr = $self->_fetch_one($number);
    die("Unable to fetch pull request $number.\n")
        unless defined $pr;
    {
        my $user = $pr->{'user'}->{'login'};
        my $title = encode_utf8( $pr->{"title"} );
        my $body = encode_utf8( $pr->{"body"} );
        my $date = $pr->{"updated_at"} || $pr->{'created_at'};
        say "Date:    $date";
        say "From:    $user";
        say "Subject: $title";
        say "Number:  $number";
        say "\n$body\n" if $body;
    }
    my $comments = _api_read( $pr->{'comments_url'} );
    foreach my $comment (@$comments) {
        my $user = $comment->{'user'}->{'login'};
        my $date = $comment->{'updated_at'} || $comment->{'created_at'};
        my $body = encode_utf8( $comment->{'body'} );
        say "-" x 79;
        say "Date: $date";
        say "From: $user";
        say "\n$body\n";
    }
    return 0;
}

lib/App/GitHubPullRequest.pm  view on Meta::CPAN

    my ($self, $number) = @_;
    die("Please specify a pull request number.\n") unless $number;
    my $pr = $self->_state($number, 'open');
    die("Unable to open pull request $number.\n")
        unless defined $pr;
    say "Pull request $number now in state: " . $pr->{'state'};
    return 0;
}


sub comment {
    my ($self, $number, $text) = @_;
    die("Please specify a pull request number.\n") unless $number;
    my $remote_repo = _find_github_remote();
    my $filename;
    unless ( $text ) {
        $filename = _tmpfile("$remote_repo-$number");
        # Find and execute editor on temporary file
        my $editor = $ENV{'EDITOR'};
        unless ( $editor ) {
            _require_binary('editor');
            $editor = 'editor';
        }
        system($editor, $filename);
        # Fetch text just edited (if any)
        if ( -r $filename ) {
            CORE::open my $fh, "<:encoding(UTF-8)", $filename or die "Can't open $filename: $!";
            $text = join "", <$fh>;
            CORE::close $fh;
        }
        # Abort if no text found
        unless ( $text ) {
            unlink $filename;
            die("Your comment is empty. Command aborted.\n");
        }
    }
    my $comment = eval {
        _api_create(
            "/repos/$remote_repo/issues/$number/comments",
            { "body" => $text },
        );
    };
    die($@ . ( defined $filename ? "Comment text saved in '$filename'. Please remove it manually." : "" ) ."\n")
        if $@; # most likely network error
    die("Unable to add comment on pull request $number.\n")
        unless defined $comment;
    say "Comment added. You can view it online here: " . $comment->{'html_url'};

    # Remove temporary file if everything went well
    if ( defined $filename and -e $filename ) {
        my $count = unlink $filename;
        warn("Unable to remove temporary file $filename: $!\n")
            unless $count;
    }

    return 0;
}


sub login {
    my ($self, $user, $password, $two_factor_token) = @_;

    # Add deprecation message
    say "\nThis authorization method is deprecated and will be removed on November 13, 2020.";
    say "Please use the 'authorize' command to authenticate with GitHub.\n";

    # Try to fetch user/password from git config (or prompt)
    $user     ||= _qx('git', "config github.user")     || _prompt('GitHub username');
    $password ||= _qx('git', "config github.password") || _prompt('GitHub password', 'hidden');
    die("Please specify a user name.\n") unless $user;
    die("Please specify a password.\n")  unless $password;
    # Prompt for two-factor auth token
    $two_factor_token ||= _prompt('GitHub two-factor authentication token (if any)');

    # Perform authentication
    my $auth = _api_create(
        "/authorizations",
        {
            "scopes"   => [qw( public_repo repo )],
            "note"     => __PACKAGE__,
            "note_url" => 'https://metacpan/module/' . __PACKAGE__,
        },
        $user,
        $password,
        $two_factor_token,
    );
    die("Unable to authenticate with GitHub.\n")
        unless defined $auth;
    my $token = $auth->{'token'};
    die("Authentication data does not include a token.\n")
        unless $token;

    # Store authorization token
    my ($content, $rc) = _run_ext(qw(git config --global github.pr-token), $token);
    die("git config returned message '$content' and code $rc when trying to store your token.\n")
        if $rc != 0;
    say "Access token stored successfully. Go to https://github.com/settings/tokens to revoke access.";
    return 0;
}


sub authorize {
    my ($self, $token) = @_;
    # Verify that you want to overwrite an existing token
    my $old_token = _qx('git', "config github.pr-token");
    if ( $old_token and not $token ) {
        say "You're already authorized.";
        my $q = _prompt("Do you want to generate a new token (y/N)") || "n";
        return 0 if lc($q) ne 'y';
    }
    # Give instructions and ask for token if not specified on command line
    unless ( $token ) {
        say "Go to https://github.com/settings/tokens/new and follow the directions to generate a new token.";
        say "Give the token a name of your choice, e.g. 'git-pr', and give it the 'repo' permission.";
        say "The 'public_repo' permission is enough if you only plan to use it with public repositories.\n";
        $token = _prompt('GitHub OAuth personal access token');
    }
    # Make sure a token is specified
    die("No token was specified. No changes have been made to your configuration.\n")
        unless $token;
    # Store authorization token
    my ($content, $rc) = _run_ext(qw(git config --global github.pr-token), $token);
    die("git config returned message '$content' and code $rc when trying to store your token.\n")
        if $rc != 0;
    say "Access token stored successfully. Go to https://github.com/settings/tokens to revoke access.";
    return 0;
}

sub _state {
    my ($self, $number, $state) = @_;
    croak("Please specify a pull request number") unless $number;
    croak("Please specify a pull request state") unless $state;
    my $remote_repo = _find_github_remote();
    return _api_update(
        "/repos/$remote_repo/pulls/$number",
        { "state" => $state },
    );
}

sub _fetch_one {
    my ($self, $number) = @_;
    my $remote_repo = _find_github_remote();
    return _api_read("/repos/$remote_repo/pulls/$number");
}

sub _find_github_remote {
    # Parse lines from git and use first found github repo
    my @repos;
    my $repo_map = {};
    foreach my $line ( _qx('git', 'remote -v') ) {
        my ($remote, $url, $type) = split /\s+/, $line;
        next unless $type eq '(fetch)'; # only consider fetch remotes
        next unless $url =~ m/github\.com/; # only consider remotes to github
        if ( $url =~ m{github.com[:/](.+?)(?:\.git)?$} ) {
            push @repos, $1;
            $repo_map->{$1} = $remote;
        }
    }

    # Allow override for testing
    @repos = ( $ENV{"GITHUB_REPO"} ) if $ENV{'GITHUB_REPO'};

    die("No valid GitHub repos found.\n")
        unless @repos;

    # Try each repo found in turn
    while ( my $repo = shift @repos ) {
        print "Fetching GitHub repo: $repo\n"
            if DEBUG;
        # Fetch repo information
        my ($repo_info, $code) = _api_read("/repos/$repo", 'return_on_error');

        # Skip repo which is not found
        if ( $code eq 404 ) {
            print STDERR "WARNING: Skipping invalid GitHub repo: $repo\n";
            if ( $repo_map->{$repo} ) {
                print STDERR "WARNING:\n";
                print STDERR "WARNING: Remove invalid git remote with command:\n";
                print STDERR "WARNING:   git remote remove $repo_map->{$repo}\n";
                print STDERR "WARNING:\n";
            }
            next;
        }

        # Return the parent repo if repo is a fork
        return $repo_info->{'parent'}->{'full_name'}
            if $repo_info->{'fork'};

        # Not a fork, use this repo
        return $repo;
    }

    die("No valid GitHub repo found. List of remotes exhausted.\n");
}

# Ask the user for some information
# Disable local echo if $hide_echo is true (for passwords)
sub _prompt {
    my ($label, $hide_echo) = @_;
    _echo('off') if $hide_echo;
    print "$label: " if defined $label;
    my $input = scalar <STDIN>;
    chomp $input;
    print "\n";
    _echo('on') if $hide_echo;
    return $input;
}

# Turn local echo on or off (Unix only for now)
sub _echo {
    my ($state) = @_;
    croak("Please specify an echo state of on or off") unless $state;

    # Test if we're on Unix (snatched from Platform::Unix)
    my $is_unix = $^O =~ /^(Linux|.*BSD.*|.*UNIX.*|Darwin|Solaris|SunOS|Haiku|Next|dec_osf|svr4|sco_sv|unicos.*|.*x)$/i;

    if ( $is_unix ) {
        return _run_ext(qw{stty -echo}) if $state eq 'off';
        return _run_ext(qw{stty echo})  if $state eq 'on';
    }

    # Don't know how to turn local echo on or off on other platforms.
    # If you happen to know how, please send a pull request with a fix
    return;
}

# Generate a random temporary filename with the given prefix
sub _tmpfile {
    my ($prefix) = @_;
    $prefix = defined $prefix ? "${prefix}-" : '';
    $prefix =~ s{[^A-Za-z0-9_-]}{-}g;
    srand($$ + time);
    my $random = $$;
    $random .= int(rand(10)) for 1..10;
    return "/tmp/git-pr-$prefix$random";
}

# Return stdout from external program
# If called in list context, each line will be chomped
# In scalar context, only last line will be chomped
sub _qx {
    my ($cmd, @rest) = @_;
    _require_binary($cmd);
    $cmd .= " " . join(" ", @rest) if @rest;
    warn("_qx: $cmd\n") if DEBUG;
    return map { chomp; $_ } qx{$cmd}
        if wantarray;
    my $content = qx{$cmd};
    chomp $content;
    return $content;
}

# Run an external command and return STDOUT and exit code
## no critic (Subroutines::RequireArgUnpacking)
sub _run_ext {
    croak("Please specify a command line") unless @_;
    my $cmd = join(" ", @_);

lib/App/GitHubPullRequest.pm  view on Meta::CPAN

        $url,                            # The URL we're GETing
    );
    die("curl failed to fetch $url with code $rc.\n") if $rc != 0;

    my $code = substr($content, -3, 3, '');

    return $content, $code if $return_on_error;

    if ( $code >= 400 ) {
        die("Fetching URL $url failed with code $code:\n$content");
    }

    return $content;
}

# Perform HTTP PATCH
sub _patch_url {
    my ($url, $mimetype, $data) = @_;
    croak("Please specify a URL") unless $url;
    croak("Please specify a mimetype") unless $mimetype;
    croak("Please specify some data") unless $data;

    # See if we should use credentials
    my @credentials;
    if ( _is_api_url($url) ) {
        my $token = _qx('git', 'config github.pr-token');
        die("You must login before you can modify information.\n")
            unless $token;
        @credentials = ( '-H', "Authorization: token $token" );
    }

    # Send request
    my ($content, $rc) = _run_ext(
        'curl',
        '-s',                            # be silent
        '-w', '%{http_code}',            # include HTTP status code at end of stdout
        '-X', 'PATCH',                   # perform an HTTP PATCH
        '-H', "Content-Type: $mimetype", # What kind of data we're sending
        '-d', $data,                     # Our data
        @credentials,                    # Logon credentials, if any
        $url,                            # The URL we're PATCHing
    );
    die("curl failed to patch $url with code $rc.\n") if $rc != 0;

    my $code = substr($content, -3, 3, '');
    if ( $code >= 400 ) {
        die("If you get 'Validation Failed' error without any reason,"
          . " most likely the pull request has already been merged or closed by the repo owner.\n"
          . "URL: $url\n"
          . "Code: $code\n"
          . $content
        ) if $code == 422;
        die("Patching URL $url failed with code $code:\n$content");
    }

    return $content;
}

# Perform HTTP POST
sub _post_url {
    my ($url, $mimetype, $data, $user, $password, $two_factor_token) = @_;
    croak("Please specify a URL") unless $url;
    croak("Please specify a mimetype") unless $mimetype;
    croak("Please specify some data") unless $data;

    # See if we should use credentials
    my @credentials;
    if ( _is_api_url($url) ) {
        my $token = _qx('git', 'config github.pr-token');
        die("You must login before you can modify information.\n")
            unless $token or ( $user and $password );
        if ( $user and $password ) {
            @credentials = ( '-u', "$user:$password" );
            push @credentials, '-H', "X-GitHub-OTP: $two_factor_token"
                if $two_factor_token;
        }
        else {
            @credentials = ( '-H', "Authorization: token $token" ) if $token;
        }
    }

    # Send request
    my ($content, $rc) = _run_ext(
        'curl',
        '-s',                            # be silent
        '-w', '%{http_code}',            # include HTTP status code at end of stdout
        '-X', 'POST',                    # perform an HTTP POST
        '-H', "Content-Type: $mimetype", # What kind of data we're sending
        '-d', $data,                     # Our data
        @credentials,                    # Logon credentials, if any
        $url,                            # The URL we're POSTing to
    );
    die("curl failed to post to $url with code $rc.\n") if $rc != 0;

    my $code = substr($content, -3, 3, '');
    if ( $code >= 400 ) {
        die("Posting to URL $url failed with code $code:\n$content");
    }

    return $content;
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

App::GitHubPullRequest - Command-line tool to query GitHub pull requests

=head1 VERSION

version 0.6.0

=head1 SYNOPSIS

    $ git pr
    $ git pr list closed # not shown by default
    $ git pr show 7      # also includes comments
    $ git pr patch 7     # can be piped to colordiff if you like colors
    $ git pr checkout 7  # create upstream tracking branch pr/7
    $ git pr help

    $ git pr authorize   # Get access token for commands below
    $ git pr close 7
    $ git pr open 7
    $ git pr comment 7 'This is good stuff!'

=head1 INSTALLATION

lib/App/GitHubPullRequest.pm  view on Meta::CPAN

L<curl(1)>

=item *

L<stty(1)>

=back

=head1 COMMANDS

=head2 help

Displays some help.

=head2 list [<state>]

Shows all pull requests in the given state. State can be either C<open> or
C<closed>.  The default state is C<open>.  This is the default command if
none is specified.

=head2 show <number>

Shows details about the specified pull request number. Also includes
comments.

=head2 patch <number>

Shows the patch associated with the specified pull request number.

=head2 checkout <number>

Checks out the specified pull request in a dedicated tracking branch.
If the remote repo is not already specified in your git config, it will be
added and the branch in question will be fetched.

=head2 close <number>

Closes the specified pull request number. Be aware, you can't close a pull
request that has already been merged.  If you try to, you'll get an obscure
C<Validation Failed> error message from the GitHub API.

=head2 open <number>

Reopens the specified pull request number. Be aware, you can't reopen a pull
request that has already been merged or closed by the repo owner.  If you
try to, you'll get an obscure C<Validation Failed> error message from the
GitHub API.

=head2 comment <number> [<text>]

Creates a comment on the specified pull request with the specified text. If
text is not specified, an editor will be opened for you to type in the text.

If your C<EDITOR> environment variable has been set, that editor is used to
edit the text.  If it has not been set, it will try to use the L<editor(1)>
external program.  This is usually a symlink set up by your operating system
to the most recently installed text editor.

The text must be encoded using UTF-8.

=head2 login [<user>] [<password>] [<2fa-token>]

DEPRECATED: Logs you in to GitHub and creates a new access token used
instead of your password and two-factor authentication token. If you don't
specify either of the options, they are looked up in your git config github
section. If none of those are found, you'll be prompted for them.

=head2 authorize [<access-token>]

Logs you in to GitHub by manually creating an OAuth personal access token.
Follow the directions on-screen to generate one and insert it when prompted.
If you already have an access token you want to use you can also specify it
on the command line.

=head1 METHODS

=head2 new

Constructor. Takes no parameters.

=head2 run(@args)

Calls any of the other listed public methods with specified arguments. This
is usually called automatically when you invoke L<git-pr>.

=head1 DEBUGGING

Set the environment variable GIT_PR_DEBUG to a non-zero value to see more
details, like each API command being executed.

If you want to interact with another GitHub repo than the one in your
current directory, set the environment variable GITHUB_REPO to the name of
the repo in question. Example:

    GITHUB_REPO=robinsmidsrod/App-GitHubPullRequest git pr list

Be aware, that if that repo is a fork, the program will look for its parent.

=head1 CAVEATS

If you don't authenticate with GitHub using the authorize command, it will use
unauthenticated API requests where possible, which has a rate-limit of 60
requests.  If you authorize first it should allow 5000 requests before you hit
the limit.

You must be standing in a directory that is a git dir and that directory must
have a remote that points to github.com for the tool to work.

=head1 SEE ALSO

=over 4

=item *

L<git-pr>

=item *

L<GitHub Pull Request documentation|https://help.github.com/articles/using-pull-requests>

=item *

L<GitHub Pull Request API documentation|http://developer.github.com/v3/pulls/>



( run in 1.203 second using v1.01-cache-2.11-cpan-39bf76dae61 )