File Coverage

lib/App/TimeTracker/Command/Jira.pm
Criterion Covered Total %
statement 32 96 33.3
branch 0 22 0.0
condition 0 3 0.0
subroutine 11 23 47.8
pod 0 1 0.0
total 43 145 29.6


line stmt bran cond sub pod time code
1             package App::TimeTracker::Command::Jira;
2 1     1   569 use strict;
  1         3  
  1         41  
3 1     1   5 use warnings;
  1         1  
  1         27  
4 1     1   14 use 5.010;
  1         2  
5              
6             # ABSTRACT: App::TimeTracker Jira plugin
7 1     1   399 use App::TimeTracker::Utils qw(error_message warning_message);
  1         8996  
  1         66  
8              
9             our $VERSION = '0.8';
10              
11 1     1   395 use Moose::Role;
  1         396081  
  1         5  
12 1     1   5302 use JIRA::REST ();
  1         50235  
  1         30  
13 1     1   8 use JSON::XS qw(encode_json decode_json);
  1         2  
  1         57  
14 1     1   488 use Path::Class;
  1         29404  
  1         55  
15 1     1   7 use Try::Tiny;
  1         1  
  1         43  
16 1     1   503 use Unicode::Normalize ();
  1         1852  
  1         1505  
17              
18             has 'jira_client' => (
19             is => 'ro',
20             isa => 'Maybe[JIRA::REST]',
21             lazy_build => 1,
22             traits => ['NoGetopt'],
23             predicate => 'has_jira_client'
24             );
25             has 'jira_ticket' => (
26             is => 'ro',
27             isa => 'Maybe[HashRef]',
28             lazy_build => 1,
29             traits => ['NoGetopt'],
30             );
31             has 'jira_ticket_transitions' => (
32             is => 'rw',
33             isa => 'Maybe[ArrayRef]',
34             traits => ['NoGetopt'],
35             );
36              
37             sub _build_jira_ticket {
38 0     0     my ($self) = @_;
39              
40 0 0         if ( my $ticket = $self->_init_jira_ticket( $self->_current_task ) ) {
41 0           return $ticket;
42             }
43             }
44              
45             sub _build_jira_client {
46 0     0     my $self = shift;
47 0           my $config = $self->config->{jira};
48              
49 0 0         unless ($config) {
50 0           error_message('Please configure Jira in your TimeTracker config');
51 0           return;
52             }
53              
54 0           my $jira_client;
55             try {
56 0     0     $jira_client = JIRA::REST->new($config->{server_url}, $config->{username}, $config->{password});
57             }
58             catch {
59 0     0     error_message("Could not build JIRA client.\nEither configure username or password in your tracker config, .netrc or via Config::Identity, see perldoc JIRA::REST.\nError was:\n'%s'", $_ );
60 0           return;
61 0           };
62              
63 0           return $jira_client;
64             }
65              
66             after ['_load_attribs_start','_load_attribs_continue','_load_attribs_append'] => sub {
67             my ($class,$meta) = @_;
68              
69             $meta->add_attribute(
70             'jira' => {
71             is => 'rw',
72             isa => 'Str',
73             documentation => 'JIRA ticket ID',
74             predicate => 'has_jira'
75             }
76             );
77             return;
78             };
79              
80             before [ 'cmd_start', 'cmd_continue', 'cmd_append' ] => sub {
81             my $self = shift;
82             return unless $self->has_jira;
83              
84             $self->insert_tag('JIRA:' . $self->jira);
85              
86             my $ticket;
87             if ( $self->jira_client ) {
88             $ticket = $self->jira_ticket;
89             return unless defined $ticket;
90             if ( defined $self->description ) {
91             $self->description(
92             sprintf(
93             '%s (%s)', $self->description, $ticket->{fields}->{summary}
94             ) );
95             }
96             else {
97             $self->description( $ticket->{fields}->{summary} // '' );
98             }
99             }
100              
101             if ( $self->meta->does_role('App::TimeTracker::Command::Git') ) {
102             my $branch = $self->jira;
103             if ($ticket) {
104             my $subject = $self->_safe_ticket_subject( $ticket->{fields}->{summary} // '' );
105             $branch .= '_' . $subject;
106             }
107              
108             # Get existing branches matching the ticket number
109             my @branches = map { s/^\*?\s+//; $_ }
110             $self->repository->run('branch','--list',$self->jira.'*');
111             if (scalar @branches == 0) {
112             say 'Creating new branch "'.$branch.'".'
113             unless $self->branch || $self->no_branch;
114             } elsif (scalar @branches == 1) {
115             $branch = $branches[0];
116             } else {
117             say 'More than one branch for '.$self->jira.'? I don\'t know what to do!';
118             foreach (@branches) {
119             say " * $_";
120             }
121             return;
122             }
123              
124             $self->branch($branch)
125             unless $self->branch;
126             }
127             };
128              
129             after [ 'cmd_start', 'cmd_continue', 'cmd_append' ] => sub {
130             my $self = shift;
131             return unless $self->has_jira && $self->jira_client;
132              
133             my $ticket = $self->jira_ticket;
134             return unless defined $ticket;
135              
136             if ( $self->config->{jira}->{set_status}{start}->{transition}
137             and $self->config->{jira}->{set_status}{start}->{target_state} ) {
138             my $ticket_update_data;
139              
140             my $status = $self->config->{jira}->{set_status}{start}->{target_state};
141             if ( $status and $status ne $ticket->{fields}->{status}->{name} ) {
142             if ( my $transition_id = $self->_check_resolve_ticket_transition(
143             $self->config->{jira}->{set_status}{start}->{transition}
144             ) ) {
145             $ticket_update_data->{transition}->{id} = $transition_id;
146             }
147             }
148              
149             if ( defined $ticket_update_data ) {
150             my $result;
151             try {
152             $result = $self->jira_client->POST(
153             sprintf('/issue/%s/transitions', $self->jira),
154             undef,
155             $ticket_update_data,
156             );
157             }
158             catch {
159             error_message( 'Could not set JIRA ticket ticket status: "%s"', $_ );
160             };
161             }
162             }
163             };
164              
165             after 'cmd_stop' => sub {
166             my $self = shift;
167             return unless $self->jira_client;
168              
169             my $task = $self->_previous_task;
170             return unless $task;
171             my $task_rounded_minutes = $task->rounded_minutes;
172             return unless $task_rounded_minutes > 0;
173              
174             my $ticket = $self->_init_jira_ticket($task);
175             if ( not defined $ticket ) {
176             say
177             'Last task did not contain a JIRA ticket id, not updating TimeWorked or Status.';
178             return;
179             }
180              
181             my $do_store = 0;
182             if ( $self->config->{jira}->{log_time_spent} ) {
183             my $result;
184             try {
185             $result = $self->jira_client->POST(sprintf('/issue/%s/worklog', $task->jira_id), undef, { timeSpent => sprintf('%sm', $task_rounded_minutes) });
186             }
187             catch {
188             error_message( 'Could not log JIRA time spent: "%s"', $@ );
189             };
190             }
191              
192             my $status = $self->config->{jira}->{set_status}{stop}->{transition};
193             # Do not change the configured stop status if it has been changed since starting the ticket
194             if ( defined $status
195             and $ticket->{fields}->{status}->{name} eq
196             $self->config->{jira}->{set_status}{start}->{target_state} )
197             {
198             if ( my $transition_id = $self->_check_resolve_ticket_transition( $status ) ) {
199             my $ticket_update_data;
200             $ticket_update_data->{transition}->{id} = $transition_id;
201              
202             my $result;
203             try {
204             $result = $self->jira_client->POST(
205             sprintf('/issue/%s/transitions', $task->jira_id),
206             undef,
207             $ticket_update_data,
208             );
209             }
210             catch {
211             error_message( 'Could not set JIRA ticket status: "%s"', $@ );
212             };
213             }
214             }
215             };
216              
217             sub _init_jira_ticket {
218 0     0     my ( $self, $task ) = @_;
219 0           my $id;
220 0 0         if ($task) {
    0          
221 0           $id = $task->jira_id;
222             }
223             elsif ( $self->jira ) {
224 0           $id = $self->jira;
225             }
226 0 0         return unless defined $id;
227              
228 0           my $ticket;
229             try {
230 0     0     $ticket = $self->jira_client->GET(sprintf('/issue/%s',$id), { fields => '-comment' });
231             }
232             catch {
233 0     0     error_message( 'Could not fetch JIRA ticket: %s', $id );
234 0           };
235              
236 0           my $transitions;
237             try {
238 0     0     $transitions = $self->jira_client->GET(sprintf('/issue/%s/transitions',$id));
239             }
240             catch {
241 0     0     require Data::Dumper;
242 0           error_message( 'Could not fetch JIRA transitions for %s: %s', $id, Data::Dumper::Dumper $transitions );
243 0           };
244 0           $self->jira_ticket_transitions( $transitions->{transitions} );
245              
246 0           return $ticket;
247             }
248              
249             sub _check_resolve_ticket_transition {
250 0     0     my ( $self, $status_name ) = @_;
251 0           my $transition_id;
252              
253 0           foreach my $transition ( @{$self->jira_ticket_transitions} ) {
  0            
254 0 0 0       if ( ref $status_name and ref $status_name eq 'ARRAY' ) {
    0          
255 0           foreach my $name ( @$status_name ) {
256 0 0         if ( $transition->{name} eq $name ) {
257 0           $transition_id = $transition->{id};
258 0           last;
259             }
260             }
261             }
262             elsif ( $transition->{name} eq $status_name ) {
263 0           $transition_id = $transition->{id};
264 0           last;
265             }
266             }
267 0 0         if ( not defined $transition_id ) {
268 0           require Data::Dumper;
269             error_message( 'None of the configured ticket transitions (%s) did match the ones valid for this JIRA ticket\'s workflow-state: %s',
270 0           ref $status_name ? join(',', map { '"'.$_.'"' } @$status_name) : $status_name,
271 0 0         join(',', map { '"'.$_->{name}.'"' } @{$self->jira_ticket_transitions} ),
  0            
  0            
272             );
273 0           return;
274             }
275 0           return $transition_id;
276             }
277              
278             sub App::TimeTracker::Data::Task::jira_id {
279 0     0 0   my $self = shift;
280 0           foreach my $tag ( @{ $self->tags } ) {
  0            
281 0 0         next unless $tag =~ /^JIRA:(.+)/;
282 0           return $1;
283             }
284 0           return;
285             }
286              
287             sub _safe_ticket_subject {
288 0     0     my ( $self, $subject ) = @_;
289              
290 0           $subject = Unicode::Normalize::NFKD($subject);
291 0           $subject =~ s/\p{NonspacingMark}//g;
292 0           $subject =~ s/\W/_/g;
293 0           $subject =~ s/_+/_/g;
294 0           $subject =~ s/^_//;
295 0           $subject =~ s/_$//;
296 0           return $subject;
297             }
298              
299 1     1   692 no Moose::Role;
  1         2  
  1         9  
300             1;
301              
302             __END__
303              
304             =pod
305              
306             =encoding UTF-8
307              
308             =head1 NAME
309              
310             App::TimeTracker::Command::Jira - App::TimeTracker Jira plugin
311              
312             =head1 VERSION
313              
314             version 0.8
315              
316             =head1 DESCRIPTION
317              
318             This plugin integrates into Atlassian Jira
319             L<https://www.atlassian.com/software/jira>.
320              
321             It can set the description and tags of the current task based on data
322             coming from Jira, set the owner of the ticket and update the
323             worklog. If you also use the C<Git> plugin, this plugin will
324             generate branch names based on Jira ticket information.
325              
326             =head1 CONFIGURATION
327              
328             =head2 plugins
329              
330             Add C<Jira> to the list of plugins.
331              
332             =head2 jira
333              
334             add a hash named C<jira>, containing the following keys:
335              
336             =head3 server [REQUIRED]
337              
338             The URL of the Jira instance (without a trailing slash).
339              
340             =head3 username [OPTIONAL]
341              
342             Username to connect with.
343              
344             =head3 password [OPTIONAL]
345              
346             Password to connect with. Beware: This is stored in clear text! Better use authentication via C<Config::Identity> via C<JIRA::REST> where the credentials can be stored GPG encrypted.
347              
348             =head3 log_time_spent
349              
350             If set, an entry will be created in the ticket's work log
351              
352             =head1 NEW COMMANDS ADDED TO THE DEFAULT ONES
353              
354             none
355              
356             =head1 CHANGES TO DEFAULT COMMANDS
357              
358             =head2 start, continue
359              
360             =head3 --jira
361              
362             ~/perl/Your-Project$ tracker start --jira ABC-1
363              
364             If C<--jira> is set to a valid ticket identifier:
365              
366             =over
367              
368             =item * set or append the ticket subject in the task description ("Adding more cruft")
369              
370             =item * add the ticket number to the tasks tags ("ABC-1")
371              
372             =item * if C<Git> is also used, determine a save branch name from the ticket identifier and subject, and change into this branch ("ABC-1_adding_more_cruft")
373              
374             =item * updates the status of the ticket in Jira (given C<set_status/start/transition> is set in config)
375              
376             =back
377              
378             =head2 stop
379              
380             If C<log_time_spent> is set in config, adds and entry to the worklog of the Jira ticket.
381             If C<set_status/stop/transition> is set in config and the current Jira ticket state is C<set_status/start/target_state>, updates the status of the ticket
382              
383             =head1 EXAMPLE CONFIG
384              
385             {
386             "plugins" : [
387             "Git",
388             "Jira"
389             ],
390             "jira" : {
391             "username" : "dingo",
392             "password" : "secret",
393             "log_time_spent" : "1",
394             "server_url" : "http://localhost:8080",
395             "set_status": {
396             "start": { "transition": ["Start Progress", "Restart progress", "Reopen and start progress"], "target_state": "In Progress" },
397             "stop": { "transition": "Stop Progress" }
398             }
399             }
400             }
401              
402             =head1 AUTHOR
403              
404             Michael Kröll <pepl@cpan.org>
405              
406             =head1 COPYRIGHT AND LICENSE
407              
408             This software is copyright (c) 2019 by Michael Kröll.
409              
410             This is free software; you can redistribute it and/or modify it under
411             the same terms as the Perl 5 programming language system itself.
412              
413             =cut