File Coverage

blib/lib/App/GHPT/WorkSubmitter.pm
Criterion Covered Total %
statement 71 188 37.7
branch 14 48 29.1
condition 2 14 14.2
subroutine 16 30 53.3
pod 0 1 0.0
total 103 281 36.6


line stmt bran cond sub pod time code
1             package App::GHPT::WorkSubmitter;
2              
3 1     1   7655 use App::GHPT::Wrapper::OurMoose;
  1         2  
  1         7  
4              
5             our $VERSION = '1.000012';
6              
7 1     1   625 use App::GHPT::Types qw( ArrayRef Bool PositiveInt Str );
  1         4  
  1         9  
8 1     1   8918 use App::GHPT::WorkSubmitter::AskPullRequestQuestions;
  1         5  
  1         52  
9 1     1   704 use File::HomeDir ();
  1         4364  
  1         38  
10 1     1   8 use IPC::Run3 qw( run3 );
  1         3  
  1         77  
11 1     1   788 use Lingua::EN::Inflect qw( PL PL_V );
  1         30486  
  1         188  
12 1     1   669 use List::AllUtils qw( part );
  1         3771  
  1         101  
13 1     1   9 use Path::Class qw( dir file );
  1         2  
  1         63  
14 1     1   475 use Term::CallEditor qw( solicit );
  1         1480  
  1         68  
15 1     1   665 use Term::Choose qw( choose );
  1         20956  
  1         80  
16 1     1   9 use WebService::PivotalTracker 0.10;
  1         30  
  1         4204  
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         3  
134 2         61 my $want = $self->project;
135             return [
136 5         253 map { $_->id }
137 2 100       63 grep { $want ? ( $_->name =~ /\Q$want/i ) : 1 }
  8         512  
138             $self->_pt_api->projects->@*
139             ];
140             }
141              
142 3     3   16771 sub _find_project ($self) {
  3         8  
  3         7  
143 3         99 my $want = $self->project;
144 3 100       84 my @projects = grep { $want ? ( $_->name =~ /\Q$want/i ) : 1 }
  12         102  
145             $self->_pt_api->projects->@*;
146              
147 3 100       16 return $projects[0] if @projects == 1;
148              
149 2         7 my %project_by_name = map { $_->name => $_ } @projects;
  7         25  
150 2         23 my $name = $self->_choose( [ sort keys %project_by_name ] );
151 2         48 return $project_by_name{$name};
152             }
153              
154 3     3   9212 sub _find_requester ( $self, $project ) {
  3         6  
  3         5  
  3         4  
155 3         103 my $want = $self->requester;
156 3 100       28 my @memberships = grep { $want ? ( $_->person->name =~ /\Q$want/i ) : 1 }
  6         718  
157             $project->memberships->@*;
158              
159 3 100       18 return $memberships[0]->person if @memberships == 1;
160              
161             my %membership_by_person_name
162 2         5 = map { $_->person->name => $_ } @memberships;
  4         16  
163 2         18 my $name = $self->_choose( [ sort keys %membership_by_person_name ] );
164 2         46 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 0     0   0 sub _pt_token ($self) {
  0         0  
  0         0  
216 0         0 my $key = 'submit-work.pivotaltracker.token';
217 0   0     0 return $self->_config_val($key) // $self->_require_git_config($key);
218             }
219             ## use critic
220              
221             sub _choose {
222 0     0   0 my $self = shift;
223 0   0     0 return choose(@_)
224             || exit 1; # user hit q or ctrl-d to quit
225             }
226              
227 0     0   0 sub _choose_pt_story ($self) {
  0         0  
  0         0  
228 0 0       0 if ( $self->create_story ) {
229 0         0 my $project = $self->_find_project;
230 0         0 my $requester = $self->_find_requester($project);
231 0         0 my $name = $self->_get_story_name;
232              
233 0 0       0 if ( $self->dry_run ) {
234 0         0 say "Would create story $name in "
235             . $project->name
236             . ' with requester '
237             . $requester->name
238             . ' but this is a dry-run.';
239 0         0 exit;
240             }
241              
242             return (
243 0         0 $requester->name,
244             $self->_pt_api->create_story(
245             current_state => 'started',
246              
247             # This is primarily intended for small changes/stories, so 0 points.
248             estimate => 0,
249             name => $name,
250             owner_ids => [ $self->_pt_api->me->id ],
251             project_id => $project->id,
252             requested_by_id => $requester->id,
253             )
254             );
255             }
256              
257             my $stories = [
258             map {
259 0         0 $self->_pt_api->project_stories_where(
  0         0  
260             project_id => $_,
261             filter => sprintf(
262             '(owner:%s AND (state:started OR state:finished))',
263             $self->_username
264             ),
265             )->@*
266             } $self->_project_ids->@*
267             ];
268              
269 0         0 $stories = $self->_filter_chores_and_maybe_warn_user($stories);
270              
271 0 0       0 return undef unless $stories->@*;
272              
273 0         0 my %stories_lookup = map { $_->name => $_ } $stories->@*;
  0         0  
274 0         0 my $chosen_story = $self->_choose( [ sort keys %stories_lookup ] );
275              
276             return (
277             $stories_lookup{$chosen_story}->requested_by->name,
278 0         0 $stories_lookup{$chosen_story}
279             );
280             }
281              
282 0     0   0 sub _get_story_name ($self) {
  0         0  
  0         0  
283 0         0 my $story_name = $self->story_name;
284 0 0       0 if ( !$story_name ) {
285 0         0 say q{Please enter the new story's name:};
286 0         0 $story_name = $self->_read_line;
287             }
288 0         0 return $story_name;
289             }
290              
291 0     0   0 sub _read_line ($) {
  0         0  
292 0         0 while (1) {
293 0         0 my $l = readline( \*STDIN );
294 0         0 $l =~ s/^\s+|\s+$//g;
295 0 0       0 return $l if $l;
296             }
297             }
298              
299 3     3   14446 sub _filter_chores_and_maybe_warn_user ( $self, $stories ) {
  3         6  
  3         5  
  3         6  
300             my ( $chore_stories, $non_chore_stories )
301 3 100   12   21 = part { $_->story_type eq 'chore' ? 0 : 1 } $stories->@*;
  12         664  
302              
303 3 100       26 say 'Note: '
304             . ( scalar $chore_stories->@* )
305             . PL( ' chore', scalar $chore_stories->@* )
306             . PL_V( ' is', scalar $chore_stories->@* )
307             . ' not shown here (chores by definition do not require review).'
308             if $chore_stories;
309              
310 3   100     4347 return $non_chore_stories // [];
311             }
312              
313 0     0     sub _confirm_story ( $self, $text ) {
  0            
  0            
  0            
314 0           my $result = $self->_choose( [ 'Accept', 'Edit' ], { prompt => $text } );
315 0 0         return $text if $result eq 'Accept';
316 0           my $fh = solicit($text);
317 0           return do { local $/ = undef; <$fh> };
  0            
  0            
318             }
319              
320 0     0     sub _text_for_story ( $self, $story, $reviewer ) {
  0            
  0            
  0            
  0            
321 0 0         join "\n\n",
    0          
322             $story->name,
323             $story->url,
324             ( $story->description ? $story->description : () ),
325             (
326             $self->_include_requester_name_in_pr
327             ? 'Reviewer: ' . $reviewer
328             : ()
329             ),
330             ;
331             }
332              
333 0     0     sub _create_pull_request ( $self, $text ) {
  0            
  0            
  0            
334 0 0         if ( $self->dry_run ) {
335 0           print $text;
336 0           exit;
337             }
338              
339             run3(
340 0           [ qw(hub pull-request -F - -b), $self->base ],
341             \$text,
342             \my $hub_output,
343             \my $err,
344             {
345             binmode_stdin => ':encoding(UTF-8)',
346             binmode_stdout => ':encoding(UTF-8)',
347             binmode_stderr => ':encoding(UTF-8)',
348             },
349             );
350              
351 0 0         warn $err if $err;
352 0 0         exit 1 if $?;
353              
354 0           return $hub_output;
355             }
356              
357 0     0     sub _update_pt_story ( $self, $story, $pr_url ) {
  0            
  0            
  0            
  0            
358 0           $story->update( current_state => 'finished' );
359 0           $story->add_comment( text => $pr_url );
360 0           return;
361             }
362              
363 0     0     sub _build_git_config ($self) {
  0            
  0            
364 0           run3(
365             [ 'git', 'config', '--list' ],
366             \undef,
367             \my @conf_values,
368             \my $error,
369             );
370              
371 0 0 0       if ( $error || $? ) {
372 0 0         die q{Could not run "git config --list"}
373             . ( defined $error ? ": $error" : q{} );
374             }
375              
376             return {
377 0           map { split /=/, $_, 2 }
378 0           grep {/^submit-work/}
379             ## no critic (BuiltinFunctions::ProhibitComplexMappings)
380 0           map { chomp; $_ } @conf_values
  0            
  0            
381             };
382             }
383              
384 0     0     sub _require_git_config ( $self, $key ) {
  0            
  0            
  0            
385 0           die "Please set $key using 'git config --global $key VALUE'\n";
386             }
387              
388             __PACKAGE__->meta->make_immutable;
389              
390             1;