File Coverage

blib/lib/App/GHPT/WorkSubmitter.pm
Criterion Covered Total %
statement 77 189 40.7
branch 14 48 29.1
condition 4 19 21.0
subroutine 17 30 56.6
pod 0 1 0.0
total 112 287 39.0


line stmt bran cond sub pod time code
1             package App::GHPT::WorkSubmitter;
2              
3 1     1   7915 use App::GHPT::Wrapper::OurMoose;
  1         4  
  1         10  
4              
5             our $VERSION = '1.001000';
6              
7 1     1   790 use App::GHPT::Types qw( ArrayRef Bool PositiveInt Str );
  1         3  
  1         10  
8 1     1   8881 use App::GHPT::WorkSubmitter::AskPullRequestQuestions;
  1         4  
  1         61  
9 1     1   793 use File::HomeDir ();
  1         4747  
  1         40  
10 1     1   8 use IPC::Run3 qw( run3 );
  1         3  
  1         82  
11 1     1   887 use Lingua::EN::Inflect qw( PL PL_V );
  1         26690  
  1         131  
12 1     1   803 use List::AllUtils qw( part );
  1         4112  
  1         107  
13 1     1   10 use Path::Class qw( dir file );
  1         2  
  1         63  
14 1     1   552 use Term::CallEditor qw( solicit );
  1         3308  
  1         75  
15 1     1   713 use Term::Choose qw( choose );
  1         123272  
  1         250  
16 1     1   24 use WebService::PivotalTracker 0.10;
  1         63  
  1         5531  
17              
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 => 'master',
41             documentation =>
42             'The branch against which you want base the pull request. This defaults to master.',
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 requester => (
53             is => 'ro',
54             isa => Str,
55             default => q{},
56             documentation =>
57             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.},
58             );
59              
60             has story_name => (
61             is => 'ro',
62             isa => Str,
63             default => q{},
64             documentation =>
65             'When creating a story, this is the name (title) to set.',
66             );
67              
68             has _question_namespaces => (
69             is => 'ro',
70             isa => ArrayRef [Str],
71             lazy => 1,
72             default => sub ($self) {
73             my $ns = $self->_config_val('submit-work.question-namespaces');
74             [
75             $ns
76             ? ( split / +/, $ns )
77             : 'App::GHPT::WorkSubmitter::Question'
78             ];
79             },
80             );
81              
82             has _username => (
83             is => 'ro',
84             isa => Str,
85             lazy => 1,
86             default => sub ($self) {
87             my $key = 'submit-work.pivotaltracker.username';
88             $self->_config_val($key) // $self->_require_git_config($key);
89             },
90             );
91              
92             has _pt_api => (
93             is => 'ro',
94             isa => 'WebService::PivotalTracker',
95             lazy => 1,
96             builder => '_build_pt_api',
97             documentation =>
98             'A WebService::PivotalTracker object built using $self->_pt_token',
99             );
100              
101             has _include_requester_name_in_pr => (
102             is => 'ro',
103             isa => 'Bool',
104             lazy => 1,
105             default => sub ($self) {
106             return $self->_config_val('submit-work.include-requester-name-in-pr')
107             // 1;
108             },
109             );
110              
111             has _git_config => (
112             traits => ['Hash'],
113             is => 'ro',
114             isa => 'HashRef',
115             lazy => 1,
116             builder => '_build_git_config',
117             handles => { _config_val => 'get' },
118             );
119              
120 0     0   0 sub _build_pt_api ($self) {
  0         0  
  0         0  
121 0         0 return WebService::PivotalTracker->new(
122             token => $self->_pt_token,
123             );
124             }
125              
126             has _project_ids => (
127             is => 'ro',
128             isa => ArrayRef [PositiveInt],
129             lazy => 1,
130             builder => '_build_project_ids',
131             );
132              
133 2     2   4 sub _build_project_ids ($self) {
  2         4  
  2         15  
134 2         77 my $want = $self->project;
135             return [
136 5         262 map { $_->id }
137 2 100       74 grep { $want ? ( $_->name =~ /\Q$want/i ) : 1 }
  8         644  
138             $self->_pt_api->projects->@*
139             ];
140             }
141              
142 3     3   15348 sub _find_project ($self) {
  3         7  
  3         6  
143 3         96 my $want = $self->project;
144 3 100       84 my @projects = grep { $want ? ( $_->name =~ /\Q$want/i ) : 1 }
  12         87  
145             $self->_pt_api->projects->@*;
146              
147 3 100       16 return $projects[0] if @projects == 1;
148              
149 2         6 my %project_by_name = map { $_->name => $_ } @projects;
  7         21  
150 2         19 my $name = $self->_choose( [ sort keys %project_by_name ] );
151 2         57 return $project_by_name{$name};
152             }
153              
154 3     3   10587 sub _find_requester ( $self, $project ) {
  3         7  
  3         5  
  3         6  
155 3         101 my $want = $self->requester;
156 3 100       19 my @memberships = grep { $want ? ( $_->person->name =~ /\Q$want/i ) : 1 }
  6         738  
157             $project->memberships->@*;
158              
159 3 100       19 return $memberships[0]->person if @memberships == 1;
160              
161             my %membership_by_person_name
162 2         6 = map { $_->person->name => $_ } @memberships;
  4         33  
163 2         17 my $name = $self->_choose( [ sort keys %membership_by_person_name ] );
164 2         43 return $membership_by_person_name{$name}->person;
165             }
166              
167             before print_usage_text => sub {
168             say <<'EOF';
169             Please see POD in App::GHPT for installation and troubleshooting directions.
170             EOF
171             };
172              
173 0     0 0 0 sub run ($self) {
  0         0  
  0         0  
174 0 0       0 unless ( -s dir( File::HomeDir->my_home )->file( '.config', 'hub' ) ) {
175 0         0 die
176             "hub does not appear to be set up. Please run 'hub browse' to set it up.\n";
177             }
178              
179 0         0 my ( $requester, $chosen_story ) = $self->_choose_pt_story;
180 0 0       0 unless ($requester) {
181 0         0 die "No requester found!\n";
182             }
183 0 0       0 unless ($chosen_story) {
184 0         0 die "No started stories found!\n";
185             }
186              
187 0         0 my $pull_request_url = $self->_create_pull_request(
188             $self->_append_question_answers(
189             $self->_confirm_story(
190             $self->_text_for_story( $chosen_story, $requester ),
191             ),
192             ),
193             );
194 0         0 $self->_update_pt_story( $chosen_story, $pull_request_url );
195 0         0 say $chosen_story->url;
196 0         0 say $pull_request_url;
197              
198 0         0 return 0;
199             }
200              
201 0     0   0 sub _append_question_answers ( $self, $text ) {
  0         0  
  0         0  
  0         0  
202 0         0 my $qa_markdown = App::GHPT::WorkSubmitter::AskPullRequestQuestions->new(
203             merge_to_branch_name => 'origin/' . $self->base,
204             question_namespaces => $self->_question_namespaces,
205             )->ask_questions;
206 0 0 0     0 return $text unless defined $qa_markdown and length $qa_markdown;
207 0         0 return join "\n\n",
208             $text,
209             '----',
210             $qa_markdown,
211             ;
212             }
213              
214             ## no critic (Subroutines::ProhibitUnusedPrivateSubroutines)
215 1     1   24 sub _pt_token ($self) {
  1         8  
  1         3  
216 1         3 my $env_key = 'PIVOTALTRACKER_TOKEN';
217 1         4 my $key = 'submit-work.pivotaltracker.token';
218 1   33     14 return $ENV{$env_key} // $self->_config_val($key)
      33        
219             // $self->_require_git_config($key);
220             }
221             ## use critic
222              
223             sub _choose {
224 0     0   0 my $self = shift;
225 0   0     0 return choose(@_)
226             || exit 1; # user hit q or ctrl-d to quit
227             }
228              
229 0     0   0 sub _choose_pt_story ($self) {
  0         0  
  0         0  
230 0 0       0 if ( $self->create_story ) {
231 0         0 my $project = $self->_find_project;
232 0         0 my $requester = $self->_find_requester($project);
233 0         0 my $name = $self->_get_story_name;
234              
235 0 0       0 if ( $self->dry_run ) {
236 0         0 say "Would create story $name in "
237             . $project->name
238             . ' with requester '
239             . $requester->name
240             . ' but this is a dry-run.';
241 0         0 exit;
242             }
243              
244             return (
245 0         0 $requester->name,
246             $self->_pt_api->create_story(
247             current_state => 'started',
248              
249             # This is primarily intended for small changes/stories, so 0 points.
250             estimate => 0,
251             name => $name,
252             owner_ids => [ $self->_pt_api->me->id ],
253             project_id => $project->id,
254             requested_by_id => $requester->id,
255             )
256             );
257             }
258              
259             my $stories = [
260             map {
261 0         0 $self->_pt_api->project_stories_where(
  0         0  
262             project_id => $_,
263             filter => sprintf(
264             '(owner:%s AND (state:started OR state:finished))',
265             $self->_username
266             ),
267             )->@*
268             } $self->_project_ids->@*
269             ];
270              
271 0         0 $stories = $self->_filter_chores_and_maybe_warn_user($stories);
272              
273 0 0       0 return undef unless $stories->@*;
274              
275 0         0 my %stories_lookup = map { $_->name => $_ } $stories->@*;
  0         0  
276 0         0 my $chosen_story = $self->_choose( [ sort keys %stories_lookup ] );
277              
278             return (
279             $stories_lookup{$chosen_story}->requested_by->name,
280 0         0 $stories_lookup{$chosen_story}
281             );
282             }
283              
284 0     0   0 sub _get_story_name ($self) {
  0         0  
  0         0  
285 0         0 my $story_name = $self->story_name;
286 0 0       0 if ( !$story_name ) {
287 0         0 say q{Please enter the new story's name:};
288 0         0 $story_name = $self->_read_line;
289             }
290 0         0 return $story_name;
291             }
292              
293 0     0   0 sub _read_line ($) {
  0         0  
294 0         0 while (1) {
295 0         0 my $l = readline( \*STDIN );
296 0         0 $l =~ s/^\s+|\s+$//g;
297 0 0       0 return $l if $l;
298             }
299             }
300              
301 3     3   15589 sub _filter_chores_and_maybe_warn_user ( $self, $stories ) {
  3         5  
  3         7  
  3         4  
302             my ( $chore_stories, $non_chore_stories )
303 3 100   12   23 = part { $_->story_type eq 'chore' ? 0 : 1 } $stories->@*;
  12         725  
304              
305 3 100       39 say 'Note: '
306             . ( scalar $chore_stories->@* )
307             . PL( ' chore', scalar $chore_stories->@* )
308             . PL_V( ' is', scalar $chore_stories->@* )
309             . ' not shown here (chores by definition do not require review).'
310             if $chore_stories;
311              
312 3   100     4418 return $non_chore_stories // [];
313             }
314              
315 0     0     sub _confirm_story ( $self, $text ) {
  0            
  0            
  0            
316             my $result = $self->_choose(
317             [ 'Accept', 'Edit' ],
318 0   0       { prompt => $text, clear_screen => $ENV{'SUBMIT_WORK_CLEAR'} // 0 }
319             );
320 0 0         return $text if $result eq 'Accept';
321 0           my $fh = solicit($text);
322 0           return do { local $/ = undef; <$fh> };
  0            
  0            
323             }
324              
325 0     0     sub _text_for_story ( $self, $story, $reviewer ) {
  0            
  0            
  0            
  0            
326 0 0         join "\n\n",
    0          
327             $story->name,
328             $story->url,
329             ( $story->description ? $story->description : () ),
330             (
331             $self->_include_requester_name_in_pr
332             ? 'Reviewer: ' . $reviewer
333             : ()
334             ),
335             ;
336             }
337              
338 0     0     sub _create_pull_request ( $self, $text ) {
  0            
  0            
  0            
339 0 0         if ( $self->dry_run ) {
340 0           print $text;
341 0           exit;
342             }
343              
344             run3(
345 0           [ qw(hub pull-request -F - -b), $self->base ],
346             \$text,
347             \my $hub_output,
348             \my $err,
349             {
350             binmode_stdin => ':encoding(UTF-8)',
351             binmode_stdout => ':encoding(UTF-8)',
352             binmode_stderr => ':encoding(UTF-8)',
353             },
354             );
355              
356 0 0         warn $err if $err;
357 0 0         exit 1 if $?;
358              
359 0           return $hub_output;
360             }
361              
362 0     0     sub _update_pt_story ( $self, $story, $pr_url ) {
  0            
  0            
  0            
  0            
363 0           $story->update( current_state => 'finished' );
364 0           $story->add_comment( text => $pr_url );
365 0           return;
366             }
367              
368 0     0     sub _build_git_config ($self) {
  0            
  0            
369 0           run3(
370             [ 'git', 'config', '--list' ],
371             \undef,
372             \my @conf_values,
373             \my $error,
374             );
375              
376 0 0 0       if ( $error || $? ) {
377 0 0         die q{Could not run "git config --list"}
378             . ( defined $error ? ": $error" : q{} );
379             }
380              
381             return {
382 0           map { split /=/, $_, 2 }
383 0           grep {/^submit-work/}
384             ## no critic (BuiltinFunctions::ProhibitComplexMappings)
385 0           map { chomp; $_ } @conf_values
  0            
  0            
386             };
387             }
388              
389 0     0     sub _require_git_config ( $self, $key ) {
  0            
  0            
  0            
390 0           die "Please set $key using 'git config --global $key VALUE'\n";
391             }
392              
393             __PACKAGE__->meta->make_immutable;
394              
395             1;