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