File Coverage

lib/App/TimeTracker/Command/Jira.pm
Criterion Covered Total %
statement 35 97 36.0
branch 0 24 0.0
condition 0 6 0.0
subroutine 12 22 54.5
pod 0 1 0.0
total 47 150 31.3


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