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;
}


sub patch {
    my ($self, $number) = @_;
    die("Please specify a pull request number.\n") unless $number;
    my $patch = _get_url(
        $self->_fetch_one($number)->{'patch_url'}
    );
    die("Unable to fetch patch for pull request $number.\n")
        unless defined $patch;
    print $patch;
    return 0;
}


sub checkout {
    my ($self, $number) = @_;
    $number =~ s{[^\d]}{}g if defined $number;
    die("Please specify a pull request number.\n") unless $number;
    my $pr = $self->_fetch_one($number);

    # Get required contributor branch info
    my $head_repo   = $pr->{'head'}->{'repo'}->{'clone_url'};
    my $head_branch = $pr->{'head'}->{'ref'};
    my $head_user   = $pr->{'head'}->{'user'}->{'login'};

    # Check if the remote already exists in our repo
    my $head_remote;
    foreach my $line ( _qx("git", "remote -v") ) {
        my ($remote, $url, $type) = split /\s+/, $line;
        next unless $type eq '(fetch)'; # only consider fetch remotes
        if ( $url eq $head_repo ) {
            $head_remote = $remote;
            last;
        }
    }

    if ( $head_remote ) {
        # Remote already exists, try to update its state
        unless ( _qx(qw(git branch --list -r), qq{"$head_remote/$head_branch"}) ) {
            # Create a new tracking branch, because one doesn't already exist
            my ($content, $rc) = _run_ext(
                qw(git remote set-branches),
                '--add',        # don't remove any other existing tracking branches
                $head_remote,   # our remote's name/alias
                $head_branch,   # the ref we want to track
            );
            die("git failed with error $rc when trying to add tracking branch"
              . " to existing remote.\n")
                if $rc != 0;
        }

        # Fetch changes from just added remote
        say "Fetching changes from '$head_remote/$head_branch'";
        my ($content, $rc) = _run_ext(
            qw(git fetch),
            $head_remote,
        );
        die("git failed with error $rc when trying to update remote.\n")
            if $rc != 0;
    }
    else {
        # Create and fetch the branch info if it doesn't exist already
        $head_remote = $head_user;
        my ($content, $rc) = _run_ext(
            qw(git remote add),
            '-f',                    # only fetch specific refs
            '-t', $head_branch,      # add only a ref for this ref, not all
            $head_remote,            # what we'll name our remote
            $head_repo,              # URL to the head repo
        );
        die("git failed with error $rc when trying to add remote.\n")
            if $rc != 0;
    }

    # Actually checkout the ref we just updated as pr/<number>
    my ($content, $rc) = _run_ext(
        qw(git checkout),
        '-b', "pr/$number",
        '--track', "$head_remote/$head_branch",
    );
    die("git failed with error $rc when trying to check out branch.\n")
        if $rc != 0;

    return 0;
}


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


sub open { ## no critic (Subroutines::ProhibitBuiltinHomonyms)
    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



( run in 1.257 second using v1.01-cache-2.11-cpan-d7a12ab2c7f )