File Coverage

blib/lib/App/GHPT/WorkSubmitter.pm
Criterion Covered Total %
statement 80 247 32.3
branch 14 68 20.5
condition 4 49 8.1
subroutine 18 38 47.3
pod 0 1 0.0
total 116 403 28.7


line stmt bran cond sub pod time code
1              
2             use App::GHPT::Wrapper::OurMoose;
3 1     1   6780  
  1         2  
  1         10  
4             our $VERSION = '2.000000';
5              
6             use App::GHPT::Types qw( ArrayRef Bool PositiveInt Str );
7 1     1   589 use App::GHPT::WorkSubmitter::AskPullRequestQuestions;
  1         3  
  1         7  
8 1     1   7476 use File::HomeDir ();
  1         5  
  1         46  
9 1     1   645 use IPC::Run3 qw( run3 );
  1         3733  
  1         35  
10 1     1   7 use Lingua::EN::Inflect qw( PL PL_V );
  1         3  
  1         63  
11 1     1   699 use List::AllUtils qw( part );
  1         21972  
  1         151  
12 1     1   652 use Pithub ();
  1         3674  
  1         94  
13 1     1   678 use Term::CallEditor qw( solicit );
  1         194205  
  1         46  
14 1     1   505 use Term::Choose qw( choose );
  1         2732  
  1         68  
15 1     1   651 use WebService::PivotalTracker 0.10;
  1         112434  
  1         207  
16 1     1   19 use YAML::PP;
  1         58  
  1         42  
17 1     1   644  
  1         47271  
  1         4907  
18             with 'MooseX::Getopt::Dashes';
19              
20             has create_story => (
21             is => 'ro',
22             isa => Bool,
23             documentation =>
24             'If true, will create a new story instead of finding an existing one.',
25             );
26              
27             has project => (
28             is => 'ro',
29             isa => Str,
30             documentation =>
31             'The name of the PT project to search. This will be matched against the names of all the projects you have access to. By default, all projects will be searched.',
32             default => sub {
33             $ENV{APP_GHPT_PROJECT} // q{};
34             },
35             );
36              
37             has base => (
38             is => 'ro',
39             isa => Str,
40             default => 'main',
41             documentation =>
42             'The branch against which you want base the pull request. This defaults to main.',
43             );
44              
45             has dry_run => (
46             is => 'ro',
47             isa => Bool,
48             default => 0,
49             documentation => 'Dry run, just print out the PR we would have created',
50             );
51              
52             has github_protocol => (
53             is => 'ro',
54             isa => Str,
55             predicate => '_has_github_protocol',
56             documentation => 'The protocol you want to use for GitHub API request.',
57             );
58              
59             has github_token => (
60             is => 'ro',
61             isa => Str,
62             predicate => '_has_github_token',
63             documentation => 'Your GitHub token for API access.',
64             );
65              
66             has pivotaltracker_token => (
67             is => 'ro',
68             isa => Str,
69             lazy => 1,
70             builder => '_build_pivotaltracker_token',
71             documentation => 'Your Pivotal Tracker token for API access.',
72             );
73              
74             has pivotaltracker_username => (
75             is => 'ro',
76             isa => Str,
77             lazy => 1,
78             default => sub ($self) {
79             my $env_key = 'PIVOTALTRACKER_USERNAME';
80             my $key = 'submit-work.pivotaltracker.username';
81              
82             return $ENV{$env_key} // $self->_config_val($key)
83             // $self->_require_env_or_git_config( $env_key, $key );
84             },
85             documentation => 'Your Pivotal Tracker username for API access.',
86             );
87              
88             has requester => (
89             is => 'ro',
90             isa => Str,
91             default => q{},
92             documentation =>
93             q{When creating a story, this will be the requester. You can provide a substring of the person's name (case insensitive) and it will find them.},
94             );
95              
96             has story_name => (
97             is => 'ro',
98             isa => Str,
99             default => q{},
100             documentation =>
101             'When creating a story, this is the name (title) to set.',
102             );
103              
104             has _question_namespaces => (
105             is => 'ro',
106             isa => ArrayRef [Str],
107             lazy => 1,
108             default => sub ($self) {
109             my $ns = $ENV{APP_GHPT_QUESTION_NAMESPACES}
110             // $self->_config_val('submit-work.question-namespaces');
111             [
112             $ns
113             ? ( split / +/, $ns )
114             : 'App::GHPT::WorkSubmitter::Question'
115             ];
116             },
117             );
118              
119             has _github_api => (
120             is => 'ro',
121             isa => 'Pithub',
122             lazy => 1,
123             builder => '_build_github_api',
124             );
125              
126             has _github_ua => (
127             traits => ['NoGetopt'],
128             init_arg => 'github_ua',
129             is => 'ro',
130             isa => 'LWP::UserAgent',
131             predicate => '_has_github_ua',
132             );
133              
134             has _pt_api => (
135             is => 'ro',
136             isa => 'WebService::PivotalTracker',
137             lazy => 1,
138             builder => '_build_pt_api',
139             documentation =>
140             'A WebService::PivotalTracker object built using $self->pivotaltracker_token',
141             );
142              
143             has _include_requester_name_in_pr => (
144             is => 'ro',
145             isa => 'Bool',
146             lazy => 1,
147             default => sub ($self) {
148             return $ENV{APP_GHPT_INCLUDE_REQUESTER_NAME_IN_PR}
149             // $self->_config_val('submit-work.include-requester-name-in-pr')
150             // 1;
151             },
152             );
153              
154             has _git_config => (
155             traits => ['Hash'],
156             is => 'ro',
157             isa => 'HashRef',
158             lazy => 1,
159             builder => '_build_git_config',
160             handles => { _config_val => 'get' },
161             );
162              
163             has _project_ids => (
164             is => 'ro',
165             isa => ArrayRef [PositiveInt],
166             lazy => 1,
167             builder => '_build_project_ids',
168             );
169              
170             my ( $host, $user, $repo ) = $self->_github_info;
171 0     0   0 my $hub_config = $self->_hub_config->{$host}[0] // {};
  0         0  
  0         0  
172 0         0  
173 0   0     0 my $protocol = $self->_github_protocol($hub_config);
174              
175 0         0 return Pithub->new(
176             user => $user,
177 0 0       0 repo => $repo,
    0          
178             head => $self->_git_current_branch,
179             token => $self->_github_token($hub_config),
180             (
181             $host eq 'github.com' ? () : (
182             api_uri => "$protocol://$host/api/v3/",
183             )
184             ),
185             (
186             $self->_has_github_ua
187             ? (
188             ua => $self->_github_ua,
189             )
190             : (),
191             ),
192             );
193             }
194              
195             return $self->github_protocol if $self->_has_github_protocol;
196              
197 0     0   0 return $ENV{GITHUB_PROTOCOL}
  0         0  
  0         0  
  0         0  
198 0 0       0 // $self->_config_val('submit-work.github.protocol')
199             // $hub_config->{protocol} // 'https';
200             }
201              
202 0   0     0 return $self->github_token if $self->_has_github_token;
      0        
      0        
203              
204             my $env_key = 'GITHUB_TOKEN';
205 0     0   0 my $key = 'submit-work.github.token';
  0         0  
  0         0  
  0         0  
206 0 0       0 return $ENV{$env_key} // $self->_config_val($key)
207             // $hub_config->{oauth_token}
208 0         0 // $self->_require_env_or_git_config( $env_key, $key );
209 0         0 }
210              
211             my $env_key = 'PIVOTALTRACKER_TOKEN';
212 0   0     0 my $key = 'submit-work.pivotaltracker.token';
      0        
      0        
213             return $ENV{$env_key} // $self->_config_val($key)
214             // $self->_require_env_or_git_config( $env_key, $key );
215 1     1   2 }
  1         3  
  1         1  
216 1         4  
217 1         2 return WebService::PivotalTracker->new(
218 1   33     26 token => $self->pivotaltracker_token,
      33        
219             );
220             }
221              
222 0     0   0 my $want = $self->project;
  0         0  
  0         0  
223 0         0 return [
224             map { $_->id }
225             grep { $want ? ( $_->name =~ /\Q$want/i ) : 1 }
226             $self->_pt_api->projects->@*
227             ];
228 2     2   4 }
  2         3  
  2         3  
229 2         47  
230             my $want = $self->project;
231 5         199 my @projects = grep { $want ? ( $_->name =~ /\Q$want/i ) : 1 }
232 2 100       45 $self->_pt_api->projects->@*;
  8         493  
233              
234             return $projects[0] if @projects == 1;
235              
236             my %project_by_name = map { $_->name => $_ } @projects;
237 3     3   13689 my $name = $self->_choose( [ sort keys %project_by_name ] );
  3         5  
  3         5  
238 3         72 return $project_by_name{$name};
239 3 100       66 }
  12         89  
240              
241             my $want = $self->requester;
242 3 100       13 my @memberships = grep { $want ? ( $_->person->name =~ /\Q$want/i ) : 1 }
243             $project->memberships->@*;
244 2         5  
  7         19  
245 2         17 return $memberships[0]->person if @memberships == 1;
246 2         46  
247             my %membership_by_person_name
248             = map { $_->person->name => $_ } @memberships;
249 3     3   5629 my $name = $self->_choose( [ sort keys %membership_by_person_name ] );
  3         7  
  3         7  
  3         4  
250 3         80 return $membership_by_person_name{$name}->person;
251 3 100       28 }
  6         546  
252              
253             before print_usage_text => sub {
254 3 100       17 say <<'EOF';
255             Please see POD in App::GHPT for installation and troubleshooting directions.
256             EOF
257 2         4 };
  4         17  
258 2         20  
259 2         45 my ( $requester, $chosen_story ) = $self->_choose_pt_story;
260             unless ($requester) {
261             die "No requester found!\n";
262             }
263             unless ($chosen_story) {
264             die "No started stories found!\n";
265             }
266              
267             my $pull_request_url = $self->_create_pull_request(
268 0     0 0 0 $self->_append_question_answers(
  0         0  
  0         0  
269 0         0 $self->_confirm_story(
270 0 0       0 $self->_text_for_story( $chosen_story, $requester ),
271 0         0 ),
272             ),
273 0 0       0 );
274 0         0 $self->_update_pt_story( $chosen_story, $pull_request_url );
275             say $chosen_story->url;
276             say $pull_request_url;
277 0         0  
278             return 0;
279             }
280              
281             my $qa_markdown = App::GHPT::WorkSubmitter::AskPullRequestQuestions->new(
282             merge_to_branch_name => 'origin/' . $self->base,
283             question_namespaces => $self->_question_namespaces,
284 0         0 )->ask_questions;
285 0         0 return $text unless defined $qa_markdown and length $qa_markdown;
286 0         0 return join "\n\n",
287             $text,
288 0         0 '----',
289             $qa_markdown,
290             ;
291 0     0   0 }
  0         0  
  0         0  
  0         0  
292 0         0  
293             my $self = shift;
294             return choose(@_)
295             || exit 1; # user hit q or ctrl-d to quit
296 0 0 0     0 }
297 0         0  
298             if ( $self->create_story ) {
299             my $project = $self->_find_project;
300             my $requester = $self->_find_requester($project);
301             my $name = $self->_get_story_name;
302              
303             if ( $self->dry_run ) {
304             say "Would create story $name in "
305 0     0   0 . $project->name
306 0   0     0 . ' with requester '
307             . $requester->name
308             . ' but this is a dry-run.';
309             exit;
310 0     0   0 }
  0         0  
  0         0  
311 0 0       0  
312 0         0 return (
313 0         0 $requester->name,
314 0         0 $self->_pt_api->create_story(
315             current_state => 'started',
316 0 0       0  
317 0         0 # This is primarily intended for small changes/stories, so 0 points.
318             estimate => 0,
319             name => $name,
320             owner_ids => [ $self->_pt_api->me->id ],
321             project_id => $project->id,
322 0         0 requested_by_id => $requester->id,
323             )
324             );
325             }
326 0         0  
327             my $stories = [
328             map {
329             $self->_pt_api->project_stories_where(
330             project_id => $_,
331             filter => sprintf(
332             '(owner:%s AND (state:started OR state:finished))',
333             $self->pivotaltracker_username
334             ),
335             )->@*
336             } $self->_project_ids->@*
337             ];
338              
339             $stories = $self->_filter_chores_and_maybe_warn_user($stories);
340              
341             return undef unless $stories->@*;
342 0         0  
  0         0  
343             my %stories_lookup = map { $_->name => $_ } $stories->@*;
344             my $chosen_story = $self->_choose( [ sort keys %stories_lookup ] );
345              
346             return (
347             $stories_lookup{$chosen_story}->requested_by->name,
348             $stories_lookup{$chosen_story}
349             );
350             }
351              
352 0         0 my $story_name = $self->story_name;
353             if ( !$story_name ) {
354 0 0       0 say q{Please enter the new story's name:};
355             $story_name = $self->_read_line;
356 0         0 }
  0         0  
357 0         0 return $story_name;
358             }
359              
360             while (1) {
361 0         0 my $l = readline( \*STDIN );
362             $l =~ s/^\s+|\s+$//g;
363             return $l if $l;
364             }
365 0     0   0 }
  0         0  
  0         0  
366 0         0  
367 0 0       0 my ( $chore_stories, $non_chore_stories )
368 0         0 = part { $_->story_type eq 'chore' ? 0 : 1 } $stories->@*;
369 0         0  
370             say 'Note: '
371 0         0 . ( scalar $chore_stories->@* )
372             . PL( ' chore', scalar $chore_stories->@* )
373             . PL_V( ' is', scalar $chore_stories->@* )
374 0     0   0 . ' not shown here (chores by definition do not require review).'
  0         0  
375 0         0 if $chore_stories;
376 0         0  
377 0         0 return $non_chore_stories // [];
378 0 0       0 }
379              
380             my $result = $self->_choose(
381             [ 'Accept', 'Edit' ],
382 3     3   12020 { prompt => $text, clear_screen => $ENV{'SUBMIT_WORK_CLEAR'} // 0 }
  3         4  
  3         4  
  3         4  
383             );
384 3 100   12   19 return $text if $result eq 'Accept';
  12         501  
385             my $fh = solicit($text);
386 3 100       29 return do { local $/ = undef; <$fh> };
387             }
388              
389             join "\n\n",
390             $story->name,
391             $story->url,
392             ( $story->description ? $story->description : () ),
393 3   100     3295 (
394             $self->_include_requester_name_in_pr
395             ? 'Reviewer: ' . $reviewer
396 0     0     : ()
  0            
  0            
  0            
397             ),
398             ;
399 0   0       }
400              
401 0 0         if ( $self->dry_run ) {
402 0           print $text;
403 0           exit;
  0            
  0            
404             }
405              
406 0     0     my ( $title, $body ) = split /\n\n/, $text, 2;
  0            
  0            
  0            
  0            
407 0 0          
    0          
408             my $res = $self->_github_api->pull_requests->create(
409             data => {
410             base => $self->base,
411             body => $body,
412             head => $self->_git_current_branch,
413             title => $title,
414             },
415             );
416              
417             unless ( $res->success ) {
418             die "Error while creating pull request:\n\n"
419 0     0     . _format_github_error($res) . "\n";
  0            
  0            
  0            
420 0 0         }
421 0            
422 0           return $res->content->{html_url};
423             }
424              
425 0           my $content = $res->content;
426             if ( my $msg = $content->{message} ) {
427 0           if ( my $errors = $content->{errors} ) {
428             $msg .= "\n\n" . join "\n", map { $_->{message} } @$errors;
429             }
430             return $msg;
431             }
432             return $res->raw_content;
433             }
434              
435             my $git_url = $self->_git_config->{'remote.origin.url'} // q{};
436 0 0          
437 0           if ( my ( $host, $user, $repo )
438             = $git_url =~ m{^git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$} ) {
439             return ( $host, $user, $repo );
440             }
441 0            
442             my $uri = URI->new($git_url);
443             if ( $uri->can('host') && $uri->can('path') ) {
444 0     0     if ( my ( $user, $repo )
  0            
  0            
445 0           = $uri->path =~ m{/([^/]+)/([^/]+?)(?:\.git)?$} ) {
446 0 0         return ( $uri->host, $user, $repo );
447 0 0         }
448 0           }
  0            
449              
450 0           die "Unable to determine host for remote origin ($git_url)!";
451             }
452 0            
453             $story->update( current_state => 'finished' );
454             $story->add_comment( text => $pr_url );
455 0     0     return;
  0            
  0            
456 0   0       }
457              
458 0 0         run3(
459             [qw( git rev-parse --abbrev-ref HEAD )],
460 0           \undef,
461             \my $branch,
462             \my $error,
463 0           );
464 0 0 0        
465 0 0         if ( $error || $? ) {
466             die q{Could not run "git rev-parse --abbrev-ref HEAD"}
467 0           . ( defined $error ? ": $error" : q{} );
468             }
469              
470             chomp $branch;
471 0            
472             return $branch;
473             }
474 0     0      
  0            
  0            
  0            
  0            
475 0           my $file
476 0           = ( $ENV{XDG_CONFIG_HOME} // File::HomeDir->my_home . '/.config' )
477 0           . '/hub';
478              
479             return {} unless -f $file;
480 0     0      
  0            
  0            
481 0           return YAML::PP->new->load_file($file);
482             }
483              
484             run3(
485             [ 'git', 'config', '--list' ],
486             \undef,
487             \my @conf_values,
488 0 0 0       \my $error,
489 0 0         );
490              
491             if ( $error || $? ) {
492             die q{Could not run "git config --list"}
493 0           . ( defined $error ? ": $error" : q{} );
494             }
495 0            
496             return {
497             map { split /=/, $_, 2 }
498 0     0     ## no critic (BuiltinFunctions::ProhibitComplexMappings)
  0            
  0            
499             map { chomp; $_ } @conf_values
500 0   0       };
501             }
502              
503 0 0         die
504             "Please set '$env' environment variable or $key using 'git config --global $key VALUE'\n";
505 0           }
506              
507             __PACKAGE__->meta->make_immutable;
508 0     0      
  0            
  0            
509 0           1;