File Coverage

blib/lib/JIRA/Client/Automated.pm
Criterion Covered Total %
statement 155 355 43.6
branch 30 84 35.7
condition 17 67 25.3
subroutine 24 50 48.0
pod 26 26 100.0
total 252 582 43.3


line stmt bran cond sub pod time code
1 2     2   217564 use strict;
  2         19  
  2         46  
2 2     2   8 use warnings;
  2         3  
  2         35  
3 2     2   27 use 5.010;
  2         5  
4              
5             package JIRA::Client::Automated;
6             $JIRA::Client::Automated::VERSION = '1.7';
7             =head1 NAME
8              
9             JIRA::Client::Automated - A JIRA REST Client for automated scripts
10              
11             =head1 VERSION
12              
13             version 1.7
14              
15             =head1 SYNOPSIS
16              
17             use JIRA::Client::Automated;
18              
19             my $jira = JIRA::Client::Automated->new($url, $user, $password);
20              
21             # If your JIRA instance does not use username/password for authorization
22             my $jira = JIRA::Client::Automated->new($url);
23              
24             my $jira_ua = $jira->ua(); # to add in a proxy
25              
26             $jira->trace(1); # enable tracing of requests and responses
27              
28             # The simplest way to create an issue
29             my $issue = $jira->create_issue($project, $type, $summary, $description);
30              
31             # The simplest way to create a subtask
32             my $subtask = $jira->create_subtask($project, $summary, $description, $parent_key);
33              
34             # A complex but flexible way to create a new issue, story, task or subtask
35             # if you know Jira issue hash structure well.
36             my $issue = $jira->create({
37             # Jira issue 'fields' hash
38             project => {
39             key => $project,
40             },
41             issuetype => {
42             name => $type, # "Bug", "Task", "Sub-task", etc.
43             },
44             summary => $summary,
45             description => $description,
46             parent => { # only required for a subtask
47             key => $parent_key,
48             },
49             ...
50             });
51              
52              
53             my $search_results = $jira->search_issues($jql, 1, 100); # query should be a single string of JQL
54             my @issues = $jira->all_search_results($jql, 1000); # query should be a single string of JQL
55              
56             my $issue = $jira->get_issue($key);
57              
58             $jira->update_issue($key, $update_hash); # update_hash is { field => value, ... }
59             $jira->create_comment($key, $text);
60             $jira->attach_file_to_issue($key, $filename);
61              
62             $jira->transition_issue($key, $transition, $transition_hash); # transition_hash is { field => value, ... }
63              
64             $jira->close_issue($key, $resolve, $comment); # resolve is the resolution value
65             $jira->delete_issue($key);
66              
67             $jira->add_issue_watchers($key, $watcher1, ......);
68             $jira->add_issue_labels($key, $label1, ......);
69              
70              
71             =head1 DESCRIPTION
72              
73             JIRA::Client::Automated is an adapter between any automated system and JIRA's
74             REST API. This module is explicitly designed to easily create and close issues
75             within a JIRA instance via automated scripts.
76              
77             For example, if you run nightly batch jobs, you can use JIRA::Client::Automated
78             to have those jobs automatically create issues in JIRA for you when the script
79             runs into errors. You can attach error log files to the issues and then they'll
80             be waiting in someone's open issues list when they arrive at work the next day.
81              
82             If you want to avoid creating the same issue more than once you can search JIRA
83             for it first, only creating it if it doesn't exist. If it does already exist
84             you can add a comment or a new error log to that issue.
85              
86             =head1 WORKING WITH JIRA
87             6
88             Atlassian has made a very complete REST API for recent (> 5.0) versions of
89             JIRA. By virtue of being complete it is also somewhat large and a little
90             complex for the beginner. Reading their tutorials is *highly* recommended
91             before you start making hashes to update or transition issues.
92              
93             L
94              
95             This module was designed for the JIRA 5.2.11 REST API, as of March 2013, but it
96             works fine with JIRA 6.0 as well. Your mileage may vary with future versions.
97              
98             =head1 JIRA ISSUE HASH FORMAT
99              
100             When you work with an issue in JIRA's REST API, it gives you a JSON file that
101             follows this spec:
102              
103             L
104              
105             JIRA::Client::Automated tries to be nice to you and not make you deal directly
106             with JSON. When you create a new issue, you can pass in just the pieces you
107             want and L will transform them to JSON for you. The same for
108             closing and deleting issues.
109              
110             Updating and transitioning issues is more complex. Each JIRA installation will
111             have different fields available for each issue type and transition screen and
112             only you will know what they are. So in those cases you'll need to pass in an
113             "update_hash" which will be transformed to the proper JSON by the method.
114              
115             An update_hash looks like this:
116              
117             { field1 => value, field2 => value2, ...}
118              
119             For example:
120              
121             {
122             host_id => "example.com",
123             { resolution => { name => "Resolved" } }
124             }
125              
126             If you do not read JIRA's documentation about their JSON format you will hurt
127             yourself banging your head against your desk in frustration the first few times
128             you try to use L. Please RTFM.
129              
130             Note that even though JIRA requires JSON, JIRA::Client::Automated will
131             helpfully translate it to and from regular hashes for you. You only pass hashes
132             to JIRA::Client::Automated, not direct JSON.
133              
134             I recommend connecting to your JIRA server and calling L with a
135             key you know exists and then dump the result. That'll get you started.
136              
137             =head1 METHODS
138              
139             =cut
140              
141 2     2   482 use JSON;
  2         8075  
  2         17  
142 2     2   1264 use LWP::UserAgent;
  2         51755  
  2         57  
143 2     2   13 use HTTP::Request;
  2         3  
  2         46  
144 2     2   773 use HTTP::Request::Common qw(GET POST PUT DELETE);
  2         3425  
  2         106  
145 2     2   749 use LWP::Protocol::https;
  2         142867  
  2         91  
146 2     2   13 use Carp;
  2         4  
  2         95  
147 2     2   812 use Data::Dump qw(pp);
  2         8558  
  2         7553  
148              
149             =head2 new
150              
151             my $jira = JIRA::Client::Automated->new($url, $user, $password);
152              
153             Create a new JIRA::Client::Automated object by passing in the following:
154              
155             =over 3
156              
157             =item 1.
158              
159             URL for the JIRA server, such as "http://example.atlassian.net/"
160              
161             =item 2.
162              
163             Username to use to login to the JIRA server
164              
165             =item 3.
166              
167             Password for that user
168              
169             =back
170              
171             All three parameters are required if your JIRA instance uses basic
172             authorization, for which JIRA::Client::Automated must connect to the
173             JIRA instance using I username and password. You may want to set up a
174             special "auto" or "batch" username to use just for use by scripts.
175              
176             If you are using Google Account integration, the username and password to use
177             are the ones you set up at the very beginning of the registration process and
178             then never used again because Google logged you in.
179              
180             If you have other ways of authorization, like GSSAPI based authorization, do
181             not provide username or password.
182              
183             my $jira = JIRA::Client::Automated->new($url);
184              
185             =cut
186              
187             sub new {
188 1     1 1 98 my ($class, $url, $user, $password) = @_;
189              
190 1 50 33     6 unless (defined $url && $url) {
191 0         0 croak "Need to specify url to access JIRA";
192             }
193 1   33     4 my $no_user_pwd = !(defined $user || defined $password);
194 1 50 33     9 unless ($no_user_pwd || defined $user && $user && defined $password && $password) {
      33        
      33        
      33        
195 0         0 croak "Need to either specify both user and password, or provide none of them";
196             }
197              
198 1 50       4 unless ($url =~ m{/$}) {
199 1         8 $url .= '/';
200             }
201              
202             # make sure we have a usable API URL
203 1         2 my $auth_url = $url;
204 1 50       4 unless ($auth_url =~ m{/rest/api/}) {
205 1         2 $auth_url .= '/rest/api/latest/';
206             }
207 1 50       5 unless ($auth_url =~ m{/$}) {
208 0         0 $auth_url .= '/';
209             }
210 1         5 $auth_url =~ s{//}{/}g;
211 1         5 $auth_url =~ s{:/}{://};
212              
213 1 50       5 if ($auth_url !~ m|https?://|) {
214 0         0 croak "URL for JIRA must be absolute, including 'http://' or 'https://'";
215             }
216              
217 1         6 my $self = { url => $url, auth_url => $auth_url, user => $user, password => $password };
218 1         3 bless $self, $class;
219              
220             # cached UserAgent for talking to JIRA
221 1         8 $self->{_ua} = LWP::UserAgent->new();
222              
223             # cached JSON object for handling conversions
224 1         2381 $self->{_json} = JSON->new->utf8()->allow_nonref;
225              
226 1         3 return $self;
227             }
228              
229              
230             =head2 ua
231              
232             my $ua = $jira->ua();
233              
234             Returns the L object used to connect to the JIRA instance.
235             Typically used to setup proxies or make other customizations to the UserAgent.
236             For example:
237              
238             my $ua = $jira->ua();
239             $ua->env_proxy();
240             $ua->ssl_opts(...);
241             $ua->conn_cache( LWP::ConnCache->new() );
242              
243             =cut
244              
245             sub ua {
246 0     0 1 0 my $self = shift;
247 0 0       0 $self->{_ua} = shift if @_;
248 0         0 return $self->{_ua};
249             }
250              
251              
252             =head2 trace
253              
254             $jira->trace(1); # enable
255             $jira->trace(0); # disable
256             $trace = $jira->trace;
257              
258             When tracing is enabled each request and response is logged using carp.
259              
260             =cut
261              
262             sub trace {
263 12     12 1 18 my $self = shift;
264 12 50       27 $self->{_trace} = shift if @_;
265 12         26 return $self->{_trace};
266             }
267              
268              
269             sub _handle_error_response {
270 0     0   0 my ($self, $response, $request) = @_;
271              
272 0         0 my $msg = $response->status_line;
273 0 0       0 if ($response->decoded_content) {
274 0 0       0 if ($response->content_type eq 'application/json') {
275 0         0 $msg .= pp($self->{_json}->decode($response->decoded_content));
276             } else {
277 0         0 $msg .= $response->decoded_content;
278             }
279             }
280              
281 0         0 $msg .= "\n\nfor request:\n";
282 0 0       0 if ($request->decoded_content) {
283 0 0       0 if ($request->content_type eq 'application/json') {
284 0         0 $msg .= pp($self->{_json}->decode($request->decoded_content));
285             } else {
286 0         0 $msg .= $request->decoded_content;
287             }
288             }
289              
290 0         0 croak sprintf "Unable to %s %s: %s",
291             $request->method, $request->uri->path, $msg;
292             }
293              
294              
295             sub _perform_request {
296 6     6   12 my ($self, $request, $handlers) = @_;
297              
298 6 50 33     31 if ((defined $self->{user}) && (defined $self->{password})) {
299 6         19 $request->authorization_basic($self->{user}, $self->{password});
300             }
301              
302 6 50       1325 if ($self->trace) {
303 0   0     0 carp sprintf "request %s %s: %s",
304             $request->method, $request->uri->path, $request->decoded_content//'';
305             }
306              
307 6         19 my $response = $self->{_ua}->request($request);
308              
309 6 50       1179 if ($self->trace) {
310 0   0     0 carp sprintf "response %s: %s",
311             $response->status_line, $response->decoded_content//'';
312             }
313              
314 6 50       16 return $response if $response->is_success();
315              
316 0   0     0 $handlers ||= {};
317             my $handler = $handlers->{ $response->code } || sub {
318 0     0   0 return $self->_handle_error_response($response, $request);
319 0   0     0 };
320              
321 0         0 return $handler->($response, $request, $self);
322             }
323              
324              
325             =head2 create
326              
327             my $issue = $jira->create({
328             # Jira issue 'fields' hash
329             project => {
330             key => $project,
331             },
332             issuetype => {
333             name => $type, # "Bug", "Task", "SubTask", etc.
334             },
335             summary => $summary,
336             description => $description,
337             parent => { # only required for a subtask
338             key => $parent_key,
339             },
340             ...
341             });
342              
343             Creating a new issue, story, task, subtask, etc.
344              
345             Returns a hash containing only the basic information about the new issue, or
346             dies if there is an error. The hash looks like:
347              
348             {
349             id => 24066,
350             key => "TEST-57",
351             self => "https://example.atlassian.net/rest/api/latest/issue/24066"
352             }
353              
354             See also L
355              
356             =cut
357              
358             sub _issue_type_meta {
359 2     2   4 my ($self, $project, $issuetype) = @_;
360              
361 2         7 my $uri = "$self->{auth_url}issue/createmeta?projectKeys=${project}&expand=projects.issuetypes.fields";
362              
363 2         8 my $request = GET $uri,
364             Content_Type => 'application/json';
365              
366 2         6381 my $response = $self->_perform_request($request);
367              
368 2         27 my $meta = $self->{_json}->decode($response->decoded_content());
369 2         416 foreach my $p (@{$meta->{projects}}) {
  2         7  
370 2 50       6 if ($p->{key} eq $project) {
371 2         4 foreach my $i (@{$p->{issuetypes}}) {
  2         5  
372 2 50 66     12 return %{$i->{fields}} if $i->{name} eq $issuetype and exists $i->{fields};
  1         13  
373             }
374             }
375             }
376              
377 1         30 return;
378             }
379              
380             sub _custom_field_conversion_map {
381 2     2   5 my ($self, $project, $issuetype) = @_;
382              
383 2         7 my %custom_field_meta = $self->_issue_type_meta($project, $issuetype);
384              
385 2         5 my %custom_field_conversion_map;
386 2         9 while (my ($cf, $meta) = each %custom_field_meta) {
387 13 100       30 if ($cf =~ /^customfield_/) {
388 4         6 my $english_name = $meta->{name};
389 4         9 $custom_field_conversion_map{english_to_customfield}{$english_name} = $cf;
390 4         5 $custom_field_conversion_map{customfield_to_english}{$cf} = $english_name;
391 4         10 $custom_field_conversion_map{meta}{$cf} = $meta;
392             }
393             }
394              
395 2         18 return \%custom_field_conversion_map;
396             }
397              
398             sub _convert_to_custom_field_name {
399 15     15   23 my ($self, $project, $issuetype, $field_name) = @_;
400              
401 15   66     37 $self->{custom_field_conversion_map}{$project}{$issuetype} ||= $self->_custom_field_conversion_map($project, $issuetype);
402              
403 15   66     44 return $self->{custom_field_conversion_map}{$project}{$issuetype}{english_to_customfield}{$field_name} || $field_name;
404             }
405              
406             sub _convert_to_custom_field_value {
407 16     16   28 my ($self, $project, $issuetype, $field_name, $field_value) = @_;
408              
409 16   33     25 $self->{custom_field_conversion_map}{$project}{$issuetype} ||= $self->_custom_field_conversion_map($project, $issuetype);
410              
411 16         27 my $custom_field_name = $self->{custom_field_conversion_map}{$project}{$issuetype}{english_to_customfield}{$field_name};
412 16 100       22 if ($custom_field_name) { # If it's a custom field...
413 3         5 my $custom_field_defn = $self->{custom_field_conversion_map}{$project}{$issuetype}{meta}{$custom_field_name};
414 3         4 my $custom_field_name = $custom_field_defn->{custom_field_name};
415              
416 3 50       6 if (exists $custom_field_defn->{allowedValues}) {
417 3         5 my %custom_values = map { $_->{value} => $_ } @{$custom_field_defn->{allowedValues}};
  45         70  
  3         6  
418 3 50       10 my $custom_field_id = $custom_values{$field_value}{id} or die "Cannot find custom field value for $field_name value $field_value";
419 3         13 return { id => $custom_field_id };
420             } else {
421 0         0 return $field_value;
422             }
423             } else {
424             # It's a regular field, just pass it through
425 13         25 return $field_value;
426             }
427             }
428              
429             sub _convert_to_customfields {
430 3     3   5 my ($self, $project, $issuetype, $fields) = @_;
431              
432 3         4 my $converted_fields;
433 3         13 while (my ($name, $value) = each %$fields) {
434 15         25 my $converted_name = $self->_convert_to_custom_field_name($project, $issuetype, $name);
435 15         18 my $converted_value;
436 15 100       29 if (ref $value eq 'ARRAY') {
437 2         4 $converted_value = [ map { $self->_convert_to_custom_field_value($project, $issuetype, $name, $_) } @$value ];
  3         5  
438             } else {
439 13         20 $converted_value = $self->_convert_to_custom_field_value($project, $issuetype, $name, $value);
440             }
441 15         44 $converted_fields->{$converted_name} = $converted_value;
442             }
443              
444 3         7 return $converted_fields;
445             }
446              
447             sub _issuetype_custom_fieldlist {
448 1     1   6 my ($self, $project, $issuetype) = @_;
449              
450 1   33     7 $self->{custom_field_conversion_map}{$project}{$issuetype} ||= $self->_custom_field_conversion_map($project, $issuetype);
451              
452 1         4 return keys %{$self->{custom_field_conversion_map}{$project}{$issuetype}{customfield_to_english}};
  1         9  
453             }
454              
455             sub _convert_from_custom_field_name {
456 4     4   9 my ($self, $project, $issuetype, $field_name) = @_;
457              
458 4   33     12 $self->{custom_field_conversion_map}{$project}{$issuetype} ||= $self->_custom_field_conversion_map($project, $issuetype);
459              
460 4   33     13 return $self->{custom_field_conversion_map}{$project}{$issuetype}{customfield_to_english}{$field_name} || $field_name;
461             }
462              
463             sub _convert_from_customfields {
464 1     1   4 my ($self, $project, $issuetype, $fields) = @_;
465              
466             # Built-in fields
467             my $converted_fields = { map {
468 1 100       4 if ($_ !~ /^customfield_/) {
  12         23  
469 5         11 ($_ => $fields->{$_})
470             } else {
471             ()
472 7         7 }
473             } keys %$fields };
474              
475             # And the custom fields. For some reason, JIRA seems to give me a
476             # list of *all* possible custom fields, not just ones relevant to
477             # this issuetype
478 1         5 for my $cfname ($self->_issuetype_custom_fieldlist($project, $issuetype)) {
479              
480 4         12 my $english_name = $self->_convert_from_custom_field_name($project, $issuetype, $cfname);
481 4 50       10 if (exists $fields->{$cfname}) {
482 4         5 my $value = $fields->{$cfname};
483 4         4 my $converted_value;
484 4 100       12 if (ref $value eq 'ARRAY') {
    100          
485 1 50       3 $converted_value = [ map { ref $_ eq 'HASH' ? $_->{value} : $_ } @$value ];
  3         10  
486             } elsif (ref $value eq 'HASH') {
487 2         17 $converted_value = $value->{value};
488             } else {
489 1         2 $converted_value = $value;
490             }
491 4         9 $converted_fields->{$english_name} = $converted_value;
492             } else {
493 0         0 $converted_fields->{$english_name} = undef;
494             }
495             }
496              
497 1         2 return $converted_fields;
498             }
499              
500             sub create {
501 3     3 1 8 my ($self, $fields) = @_;
502              
503 3         7 my $project = $fields->{project}{key};
504 3         9 my $issuetype = $fields->{issuetype}{name};
505 3         10 my $issue = { fields => $self->_convert_to_customfields( $project, $issuetype, $fields ) };
506              
507 3         33 my $issue_json = $self->{_json}->encode($issue);
508 3         8 my $uri = "$self->{auth_url}issue/";
509              
510 3         10 my $request = POST $uri,
511             Content_Type => 'application/json',
512             Content => $issue_json;
513              
514 3         836 my $response = $self->_perform_request($request);
515              
516 3         27 my $new_issue = $self->{_json}->decode($response->decoded_content());
517              
518 3         273 return $new_issue;
519             }
520              
521              
522             =head2 create_issue
523              
524             my $issue = $jira->create_issue($project, $type, $summary, $description, $fields);
525              
526             Creating a new issue requires the project key, type ("Bug", "Task", etc.), and
527             a summary and description.
528              
529             The optional $fields parameter can be used to pass a reference to a hash of
530             extra fields to be set when the issue is created, which avoids the need for a
531             separate L call. For example:
532              
533             $jira->create_issue($project, $type, $summary, $description, {
534             labels => [ "foo", "bar" ]
535             });
536              
537             This method calls L and return the same hash reference that it does.
538              
539             =cut
540              
541             sub create_issue {
542 3     3 1 29446 my ($self, $project, $type, $summary, $description, $fields) = @_;
543              
544             my $create_fields = {
545 3 50       6 %{ $fields || {} },
  3         30  
546             summary => $summary,
547             description => $description,
548             issuetype => { name => $type, },
549             project => { key => $project, },
550             };
551              
552 3         10 return $self->create($create_fields);
553             }
554              
555              
556             =head2 create_subtask
557              
558             my $subtask = $jira->create_subtask($project, $summary, $description, $parent_key);
559             # or with optional subtask type
560             my $subtask = $jira->create_subtask($project, $summary, $description, $parent_key, 'sub-task');
561              
562             Creating a subtask. If your JIRA instance does not call subtasks "Sub-task" or
563             "sub-task", then you will need to pass in your subtask type.
564              
565             This method calls L and return the same hash reference that it does.
566              
567             =cut
568              
569             sub create_subtask {
570 0     0 1 0 my ($self, $project, $summary, $description, $parent_key, $type) = @_;
571              
572             # validate fields
573 0 0       0 die "parent_key required" unless $parent_key;
574 0   0     0 $type ||= 'Sub-task';
575              
576 0         0 my $fields = {
577             project => { key => $project, },
578             issuetype => { name => $type },
579             summary => $summary,
580             description => $description,
581             parent => { key => $parent_key},
582             };
583              
584 0         0 return $self->create($fields);
585             }
586              
587              
588             =head2 update_issue
589              
590             $jira->update_issue($key, $field_update_hash, $update_verb_hash);
591              
592             There are two ways to express the updates you want to make to an issue.
593              
594             For simple changes you pass $field_update_hash as a reference to a hash of
595             field_name => new_value pairs. For example:
596              
597             $jira->update_issue($key, { summary => $new_summary });
598              
599             That works for simple fields, but there are some, like comments, that can't be
600             updated in this way. For them you need to use $update_verb_hash.
601              
602             The $update_verb_hash parameter allow you to express a series of specific
603             operations (verbs) to be performed on each field. For example:
604              
605             $jira->update_issue($key, undef, {
606             labels => [ { remove => "test" }, { add => "another" } ],
607             comments => [ { remove => { id => 10001 } } ]
608             });
609              
610             The two forms of update can be combined in a single call.
611              
612             For more information see:
613              
614             L
615              
616             =cut
617              
618             sub update_issue {
619 0     0 1 0 my ($self, $key, $field_update_hash, $update_verb_hash) = @_;
620              
621 0         0 my $issue = {};
622 0 0       0 $issue->{fields} = $field_update_hash if $field_update_hash;
623 0 0       0 $issue->{update} = $update_verb_hash if $update_verb_hash;
624              
625 0         0 my $issue_json = $self->{_json}->encode($issue);
626 0         0 my $uri = "$self->{auth_url}issue/$key";
627              
628 0         0 my $request = PUT $uri,
629             Content_Type => 'application/json',
630             Content => $issue_json;
631              
632 0         0 my $response = $self->_perform_request($request);
633              
634 0         0 return $key;
635             }
636              
637              
638             =head2 get_issue
639              
640             my $issue = $jira->get_issue($key);
641              
642             Returns details for any issue, given its key. This call returns a hash
643             containing the information for the issue in JIRA's format. See L
644             HASH FORMAT"> for details.
645              
646             =cut
647              
648             sub get_issue {
649 1     1 1 11110 my ($self, $key) = @_;
650 1         5 my $uri = "$self->{auth_url}issue/$key";
651              
652 1         6 my $request = GET $uri, Content_Type => 'application/json';
653              
654 1         150 my $response = $self->_perform_request($request);
655              
656 1         11 my $new_issue = $self->{_json}->decode($response->decoded_content());
657              
658 1         116 my $project = $new_issue->{fields}{project}{key};
659 1         3 my $issuetype = $new_issue->{fields}{issuetype}{name};
660 1         3 my $english_fields = $self->_convert_from_customfields( $project, $issuetype, $new_issue->{fields} );
661 1         6 $new_issue->{fields} = $english_fields;
662              
663 1         6 return $new_issue;
664             }
665              
666              
667             sub _get_transitions {
668 0     0     my ($self, $key) = @_;
669 0           my $uri = "$self->{auth_url}issue/$key/transitions";
670              
671 0           my $request = GET $uri, Content_Type => 'application/json';
672              
673 0           my $response = $self->_perform_request($request);
674              
675 0           my $transitions = $self->{_json}->decode($response->decoded_content())->{transitions};
676              
677 0           return $transitions;
678             }
679              
680              
681             # Each issue could have a different workflow and therefore a different
682             # transition id for 'Close Issue', so we have to look it up every time.
683             #
684             # Also, since transition names can be freely edited ('Close', 'Close it!')
685             # we also match against the destination status name, which is much more
686             # likely to remain stable ('Closed'). This is low risk because transition
687             # names are verbs and status names are nouns, so a clash is very unlikely,
688             # or if they are the same the effect is the same ('Open').
689             #
690             # We also allow the transition names to be specified as an array of names
691             # in which case the first one that matches either a transition or status is used.
692             # This makes it easier for scripts to handle the migration of names
693             # by allowing current and new names to be used so the later change in JIRA
694             # config doesn't cause any breakage.
695              
696             sub _get_transition_id {
697 0     0     my ($self, $key, $t_name) = @_;
698              
699 0           my $transitions = $self->_get_transitions($key);
700              
701 0           my %trans_names = map { $_->{name} => $_ } @$transitions;
  0            
702 0           my %status_names = map { $_->{to}{name} => $_ } @$transitions;
  0            
703              
704 0 0         my @names = (ref $t_name) ? @$t_name : ($t_name);
705 0   0       my @trans = map { $trans_names{$_} // $status_names{$_} } @names; # // is incompatible with perl <= 5.8
  0            
706 0           my $tran = (grep { defined } @trans)[0]; # use the first defined one
  0            
707              
708 0 0         if (not defined $tran) {
709 0           my @trans2status = map { "'$_->{name}' (to '$_->{to}{name}')" } @$transitions;
  0            
710             croak sprintf "%s has no transition or reachable status called %s (available transitions: %s)",
711             $key,
712 0   0       join(", ", map { "'$_'" } @names),
  0            
713             join(", ", sort @trans2status) || '';
714             }
715              
716 0 0         return $tran->{id} unless wantarray;
717 0           return ($tran->{id}, $tran);
718             }
719              
720              
721             =head2 transition_issue
722              
723             $jira->transition_issue($key, $transition);
724             $jira->transition_issue($key, $transition, $update_hash);
725              
726             Transitioning an issue is what happens when you click the button that says
727             "Resolve Issue" or "Start Progress" on it. Doing this from code is harder, but
728             JIRA::Client::Automated makes it as easy as possible.
729              
730             You pass this method the issue key, the name of the transition or the target
731             status (spacing and capitalization matter), and an optional update_hash
732             containing any fields that you want to update.
733              
734             =head3 Specifying The Transition
735              
736             The provided $transition name is first matched against the available
737             transitions for the $key issue ('Start Progress', 'Close Issue').
738             If there's no match then the names is matched against the available target
739             status names ('Open', 'Closed'). You can use whichever is most appropriate.
740             For example, in your configuration the transition names might vary between
741             different kinds of projects but the status names might be the same.
742             In which case scripts that are meant to work across multiple projects
743             might prefer to use the status names.
744              
745             The $transition parameter can also be specified as a reference to an array of
746             names. In this case the first one that matches either a transition name or
747             status name is used. This makes it easier for scripts to work across multiple
748             kinds of projects and/or handle the migration of names by allowing current and
749             future names to be used, so the later change in JIRA config doesn't cause any
750             breakage.
751              
752             =head3 Specifying Updates
753              
754             If you have required fields on the transition screen (such as "Resolution" for
755             the "Resolve Issue" screen), you must pass those fields in as part of the
756             update_hash or you will get an error from the server. See L
757             FORMAT"> for the format of the update_hash.
758              
759             (Note: it appears that in some obscure cases missing required fields may cause the
760             transition to fail I causing an error from the server. For example
761             a field that's required but isn't configured to appear on the transition screen.)
762              
763             The $update_hash is a combination of the $field_update_hash and $update_verb_hash
764             parameters used by the L method. Like this:
765              
766             $update_hash = {
767             fields => $field_update_hash,
768             update => $update_verb_hash
769             };
770              
771             You can use it to express both simple field settings and more complex update
772             operations. For example:
773              
774             $jira->transition_issue($key, $transition, {
775             fields => { summary => $new_summary },
776             update => {
777             labels => [ { remove => "test" }, { add => "another" } ],
778             comments => [ { remove => { id => 10001 } } ]
779             }
780             });
781              
782             =cut
783              
784             sub transition_issue {
785 0     0 1   my ($self, $key, $t_name, $t_hash) = @_;
786              
787 0           my $t_id = $self->_get_transition_id($key, $t_name);
788 0           $$t_hash{transition} = { id => $t_id };
789              
790 0           my $t_json = $self->{_json}->encode($t_hash);
791 0           my $uri = "$self->{auth_url}issue/$key/transitions";
792              
793 0           my $request = POST $uri,
794             Content_Type => 'application/json',
795             Content => $t_json;
796              
797 0           my $response = $self->_perform_request($request);
798              
799 0           return $key;
800             }
801              
802              
803             =head2 close_issue
804              
805             $jira->close_issue($key);
806             $jira->close_issue($key, $resolve);
807             $jira->close_issue($key, $resolve, $comment);
808             $jira->close_issue($key, $resolve, $comment, $update_hash);
809             $jira->close_issue($key, $resolve, $comment, $update_hash, $operation);
810              
811              
812             Pass in the resolution reason and an optional comment to close an issue. Using
813             this method requires that the issue is is a status where it can use the "Close
814             Issue" transition (or other one, specified by $operation).
815             If not, you will get an error from the server.
816              
817             Resolution ("Fixed", "Won't Fix", etc.) is only required if the issue hasn't
818             already been resolved in an earlier transition. If you try to resolve an issue
819             twice, you will get an error.
820              
821             If you do not supply a comment, the default value is "Issue closed by script".
822              
823             The $update_hash can be used to set or edit the values of other fields.
824              
825             The $operation parameter can be used to specify the closing transition type. This
826             can be useful when your JIRA configuration uses nonstandard or localized
827             transition and status names, e.g.
828              
829             use utf8;
830             $jira->close_issue($key, $resolve, $comment, $update_hash, "Done");
831              
832             See L for more details.
833              
834             This method is a wrapper for L.
835              
836             =cut
837              
838             sub close_issue {
839 0     0 1   my ($self, $key, $resolve, $comment, $update_hash, $operation) = @_;
840              
841 0   0       $comment //= 'Issue closed by script'; # // is incompatible with perl <= 5.8
842 0   0       $operation //= [ 'Close Issue', 'Close', 'Closed' ];
843              
844 0   0       $update_hash ||= {};
845              
846 0           push @{$update_hash->{update}{comment}}, {
  0            
847             add => { body => $comment }
848             };
849              
850 0 0         $update_hash->{fields}{resolution} = { name => $resolve }
851             if $resolve;
852              
853 0           return $self->transition_issue($key, $operation, $update_hash);
854             }
855              
856              
857             =head2 delete_issue
858              
859             $jira->delete_issue($key);
860              
861             Deleting issues is for testing your JIRA code. In real situations you almost
862             always want to close unwanted issues with an "Oops!" resolution instead.
863              
864             =cut
865              
866             sub delete_issue {
867 0     0 1   my ($self, $key) = @_;
868              
869 0           my $uri = "$self->{auth_url}issue/$key";
870              
871 0           my $request = DELETE $uri;
872              
873 0           my $response = $self->_perform_request($request);
874              
875 0           return $key;
876             }
877              
878              
879             =head2 create_comment
880              
881             $jira->create_comment($key, $text);
882              
883             You may use any valid JIRA markup in comment text. (This is handy for tables of
884             values explaining why something in the database is wrong.) Note that comments
885             are all created by the user you used to create your JIRA::Client::Automated
886             object, so you'll see that name often.
887              
888             =cut
889              
890             sub create_comment {
891 0     0 1   my ($self, $key, $text) = @_;
892              
893 0           my $comment = { body => $text };
894              
895 0           my $comment_json = $self->{_json}->encode($comment);
896 0           my $uri = "$self->{auth_url}issue/$key/comment";
897              
898 0           my $request = POST $uri,
899             Content_Type => 'application/json',
900             Content => $comment_json;
901              
902 0           my $response = $self->_perform_request($request);
903              
904 0           my $new_comment = $self->{_json}->decode($response->decoded_content());
905              
906 0           return $new_comment;
907             }
908              
909              
910             =head2 search_issues
911              
912             my @search_results = $jira->search_issues($jql, 1, 100, $fields);
913              
914             You've used JQL before, when you did an "Advanced Search" in the JIRA web
915             interface. That's the only way to search via the REST API.
916              
917             This is a paged method. Pass in the starting result number and number of
918             results per page and it will return issues a page at a time. If you know you
919             want all of the results, you can use L instead.
920              
921             Optional parameter $fields is the arrayref containing the list of fields to be returned.
922              
923             This method returns a hashref containing up to five values:
924              
925             =over 3
926              
927             =item 1.
928              
929             total => total number of results
930              
931             =item 2.
932              
933             start => result number for the first result
934              
935             =item 3.
936              
937             max => maximum number of results per page
938              
939             =item 4.
940              
941             issues => an arrayref containing the actual found issues
942              
943             =item 5.
944              
945             errors => an arrayref containing error messages
946              
947             =back
948              
949             For example, to page through all results C<$max> at a time:
950              
951             my (@all_results, @issues);
952             do {
953             $results = $self->search_issues($jql, $start, $max);
954             if ($results->{errors}) {
955             die join "\n", @{$results->{errors}};
956             }
957             @issues = @{$results->{issues}};
958             push @all_results, @issues;
959             $start += $max;
960             } until (scalar(@issues) < $max);
961              
962             (Or just use L instead.)
963              
964             =cut
965              
966             # This is a paged method. You pass in the starting number and max to retrieve and it returns those and the total
967             # number of hits. To get the next page, call search_issues() again with the start value = start + max, until total
968             # < max
969             # Note: if $max is > 1000 (set by jira.search.views.default.max in
970             # http://jira.example.com/secure/admin/ViewSystemInfo.jspa) then it'll be truncated to 1000 anyway.
971             sub search_issues {
972 0     0 1   my ($self, $jql, $start, $max, $fields) = @_;
973              
974 0   0       $fields ||= ['*navigable'];
975 0           my $query = {
976             jql => $jql,
977             startAt => $start,
978             maxResults => $max,
979             fields => $fields,
980             };
981              
982 0           my $query_json = $self->{_json}->encode($query);
983 0           my $uri = "$self->{auth_url}search/";
984              
985 0           my $request = POST $uri,
986             Content_Type => 'application/json',
987             Content => $query_json;
988              
989             my $response = $self->_perform_request($request, {
990             400 => sub { # pass-thru 400 responses for us to deal with below
991 0     0     my ($response, $request, $self) = @_;
992 0           return $response;
993             },
994 0           });
995              
996 0 0         if ($response->code == 400) {
997 0           my $error_msg = $self->{_json}->decode($response->decoded_content());
998 0           return { total => 0, errors => $error_msg->{errorMessages} };
999             }
1000              
1001 0           my $results = $self->{_json}->decode($response->decoded_content());
1002              
1003             return {
1004             total => $$results{total},
1005             start => $$results{startAt},
1006             max => $$results{maxResults},
1007 0           issues => $$results{issues} };
1008             }
1009              
1010              
1011             =head2 all_search_results
1012              
1013             my @issues = $jira->all_search_results($jql, 1000);
1014              
1015             Like L, but returns all the results as an array of issues.
1016             You can specify the maximum number to return, but no matter what, it can't
1017             return more than the value of jira.search.views.default.max for your JIRA
1018             installation.
1019              
1020             =cut
1021              
1022             sub all_search_results {
1023 0     0 1   my ($self, $jql, $max) = @_;
1024              
1025 0           my $start = 0;
1026 0   0       $max //= 100; # is a param for testing ; // is incompatible with perl <= 5.8
1027 0           my $total = 0;
1028 0           my (@all_results, @issues, $results);
1029              
1030 0           do {
1031 0           $results = $self->search_issues($jql, $start, $max);
1032 0 0         if ($results->{errors}) {
1033 0           die join "\n", @{ $results->{errors} };
  0            
1034             }
1035 0           @issues = @{ $results->{issues} };
  0            
1036 0           push @all_results, @issues;
1037 0           $start += $max;
1038             } until (scalar(@issues) < $max);
1039              
1040 0           return @all_results;
1041             }
1042              
1043             =head2 get_issue_comments
1044              
1045             $jira->get_issue_comments($key);
1046              
1047             Returns arryref of all comments to the given issue.
1048              
1049             =cut
1050              
1051             sub get_issue_comments {
1052 0     0 1   my ($self, $key) = @_;
1053 0           my $uri = "$self->{auth_url}issue/$key/comment";
1054 0           my $request = GET $uri;
1055 0           my $response = $self->_perform_request($request);
1056 0           my $content = $self->{_json}->decode($response->decoded_content());
1057              
1058             # dereference to get just the comments arrayref
1059 0           my $comments = $content->{comments};
1060 0           return $comments;
1061             }
1062              
1063             =head2 attach_file_to_issue
1064              
1065             $jira->attach_file_to_issue($key, $filename);
1066              
1067             This method does not let you attach a comment to the issue at the same time.
1068             You'll need to call L for that.
1069              
1070             Watch out for file permissions! If the user running the script does not have
1071             permission to read the file it is trying to upload, you'll get weird errors.
1072              
1073             =cut
1074              
1075             sub attach_file_to_issue {
1076 0     0 1   my ($self, $key, $filename) = @_;
1077              
1078 0           my $uri = "$self->{auth_url}issue/$key/attachments";
1079              
1080 0           my $request = POST $uri,
1081             Content_Type => 'form-data',
1082             'X-Atlassian-Token' => 'nocheck', # required by JIRA XSRF protection
1083             Content => [file => [$filename],];
1084              
1085 0           my $response = $self->_perform_request($request);
1086              
1087 0           my $new_attachment = $self->{_json}->decode($response->decoded_content());
1088              
1089 0           return $new_attachment;
1090             }
1091              
1092              
1093             =head2 make_browse_url
1094              
1095             my $url = $jira->make_browse_url($key);
1096              
1097             A helper method to return the "C<.../browse/$key>" url for the issue.
1098             It's handy to make emails containing lists of bugs easier to create.
1099              
1100             This just appends the key to the URL for the JIRA server so that you can click
1101             on it and go directly to that issue.
1102              
1103             =cut
1104              
1105             sub make_browse_url {
1106 0     0 1   my ($self, $key) = @_;
1107             # use url + browse + key to synthesize URL
1108 0           my $url = $self->{url};
1109 0           $url =~ s/\/rest\/api\/.*//;
1110 0 0         $url .= '/' unless $url =~ m{/$};
1111 0           return $url . 'browse/' . $key;
1112             }
1113              
1114             =head2 get_link_types
1115              
1116             my $all_link_types = $jira->get_link_types();
1117              
1118             Get the arrayref of all possible link types.
1119              
1120             =cut
1121              
1122             sub get_link_types {
1123 0     0 1   my ($self) = @_;
1124              
1125 0           my $uri = "$self->{auth_url}issueLinkType";
1126 0           my $request = GET $uri;
1127 0           my $response = $self->_perform_request($request);
1128              
1129             # dereference to arrayref, for convenience later
1130 0           my $content = $self->{_json}->decode($response->decoded_content());
1131 0           my $link_types = $content->{issueLinkTypes};
1132              
1133 0           return $link_types;
1134             }
1135              
1136             =head2 link_issues
1137              
1138             $jira->link_issues($from, $to, $type);
1139              
1140             Establish a link of the type named $type from issue key $from to issue key $to .
1141             Returns nothing on success; structure containing error messages otherwise.
1142              
1143             =cut
1144              
1145              
1146             sub link_issues {
1147 0     0 1   my ($self, $from, $to, $type) = @_;
1148              
1149 0           my $uri = "$self->{auth_url}issueLink/";
1150 0           my $link = {
1151             inwardIssue => { key => $to },
1152             outwardIssue => { key => $from },
1153             type => { name => $type },
1154             };
1155              
1156 0           my $link_json = $self->{_json}->encode($link);
1157              
1158 0           my $request = POST $uri,
1159             Content_Type => 'application/json',
1160             Content => $link_json;
1161              
1162 0           my $response = $self->_perform_request($request);
1163              
1164 0 0         if($response->code != 201) {
1165 0           return $self->{_json}->decode($response->decoded_content());
1166             }
1167 0           return;
1168             }
1169              
1170             =head2 add_issue_labels
1171              
1172             $jira->add_issue_labels($issue_key, @labels);
1173              
1174             Adds one more more labels to the specified issue.
1175              
1176             =cut
1177              
1178              
1179             sub add_issue_labels {
1180 0     0 1   my ($self, $issue_key, @labels) = @_;
1181 0           $self->update_issue($issue_key, {}, { labels => [ map {{ add => $_ }} @labels ] } );
  0            
1182             }
1183              
1184             =head2 remove_issue_labels
1185              
1186             $jira->remove_issue_labels($issue_key, @labels);
1187              
1188             Removes one more more labels from the specified issue.
1189              
1190             =cut
1191              
1192             sub remove_issue_labels {
1193 0     0 1   my ($self, $issue_key, @labels) = @_;
1194 0           $self->update_issue($issue_key, {}, { labels => [ map {{ remove => $_ }} @labels ] } );
  0            
1195             }
1196              
1197             =head2 add_issue_watchers
1198              
1199             $jira->add_issue_watchers($key, @watchers);
1200              
1201             Adds watchers to the specified issue. Returns nothing if success; otherwise returns a structure containing error message.
1202              
1203             =cut
1204              
1205             sub add_issue_watchers {
1206 0     0 1   my ($self, $key, @watchers) = @_;
1207 0           my $uri = "$self->{auth_url}issue/$key/watchers";
1208 0           foreach my $w (@watchers) {
1209             my $request = POST $uri,
1210             Content_Type => 'application/json',
1211 0           Content => $self->{_json}->encode($w);
1212 0           my $response = $self->_perform_request($request);
1213 0 0         if($response->code != 204) {
1214 0           return $self->{_json}->decode($response->decoded_content());
1215             }
1216             }
1217 0           return;
1218             }
1219              
1220             =head2 get_issue_watchers
1221              
1222             $jira->get_issue_watchers($key);
1223              
1224             Returns arryref of all watchers of the given issue.
1225              
1226             =cut
1227              
1228             sub get_issue_watchers {
1229 0     0 1   my ($self, $key) = @_;
1230 0           my $uri = "$self->{auth_url}issue/$key/watchers";
1231 0           my $request = GET $uri;
1232 0           my $response = $self->_perform_request($request);
1233 0           my $content = $self->{_json}->decode($response->decoded_content());
1234              
1235             # dereference to get just the watchers arrayref
1236 0           my $watchers = $content->{watchers};
1237 0           return $watchers;
1238             }
1239              
1240             =head2 assign_issue
1241              
1242             $jira->assign_issue($key, $assignee_name);
1243              
1244             Assigns the issue to that person. Returns the key of the issue if it succeeds.
1245              
1246             =cut
1247              
1248             sub assign_issue {
1249 0     0 1   my ($self, $key, $assignee_name) = @_;
1250              
1251 0           my $assignee = {};
1252 0           $assignee->{name} = $assignee_name;
1253              
1254 0           my $issue_json = $self->{_json}->encode($assignee);
1255 0           my $uri = "$self->{auth_url}issue/$key/assignee";
1256              
1257 0           my $request = PUT $uri,
1258             Content_Type => 'application/json',
1259             Content => $issue_json;
1260              
1261 0           my $response = $self->_perform_request($request);
1262              
1263 0           return $key;
1264             }
1265              
1266             =head2 add_issue_worklog
1267              
1268             $jira->add_issue_worklog($key, $worklog);
1269              
1270             Adds a worklog to the specified issue. Returns nothing if success; otherwise returns a structure containing error message.
1271              
1272             Sample worklog:
1273             {
1274             "comment" => "I did some work here.",
1275             "started" => "2016-05-27T02:32:26.797+0000",
1276             "timeSpentSeconds" => 12000,
1277             }
1278              
1279             =cut
1280              
1281             sub add_issue_worklog {
1282 0     0 1   my ($self, $key, $worklog) = @_;
1283 0           my $uri = "$self->{auth_url}issue/$key/worklog";
1284             my $request = POST $uri,
1285             Content_Type => 'application/json',
1286 0           Content => $self->{_json}->encode($worklog);
1287 0           my $response = $self->_perform_request($request);
1288 0 0         if($response->code != 201) {
1289 0           return $self->{_json}->decode($response->decoded_content());
1290             }
1291 0           return;
1292             }
1293              
1294             =head2 get_issue_worklogs
1295              
1296             $jira->get_issue_worklogs($key);
1297              
1298             Returns arryref of all worklogs of the given issue.
1299              
1300             =cut
1301              
1302             sub get_issue_worklogs {
1303 0     0 1   my ($self, $key) = @_;
1304 0           my $uri = "$self->{auth_url}issue/$key/worklog";
1305 0           my $request = GET $uri;
1306 0           my $response = $self->_perform_request($request);
1307 0           my $content = $self->{_json}->decode($response->decoded_content());
1308              
1309             # dereference to get just the worklogs arrayref
1310 0           my $worklogs = $content->{worklogs};
1311 0           return $worklogs;
1312             }
1313              
1314             =head1 FAQ
1315              
1316             =head2 Why is there no object for a JIRA issue?
1317              
1318             Because it seemed silly. You I write such an object and give it methods
1319             to transition itself, close itself, etc., but when you are working with JIRA
1320             from batch scripts, you're never really working with just one issue at a time.
1321             And when you have a hundred of them, it's easier to not objectify them and just
1322             use JIRA::Client::Automated as a mediator. That said, if this is important to
1323             you, I wouldn't say no to a patch offering this option.
1324              
1325             =head1 BUGS
1326              
1327             Please report bugs or feature requests to the author.
1328              
1329             =head1 AUTHOR
1330              
1331             Michael Friedman
1332              
1333             =head1 CREDITS
1334              
1335             Thanks very much to:
1336              
1337             =over 4
1338              
1339             =item Tim Bunce
1340              
1341             =back
1342              
1343             =over 4
1344              
1345             =item Dominique Dumont
1346              
1347             =back
1348              
1349             =over 4
1350              
1351             =item Zhuang (John) Li <7humblerocks@gmail.com>
1352              
1353             =back
1354              
1355             =over 4
1356              
1357             =item Ivan E. Panchenko
1358              
1359             =back
1360              
1361             =encoding utf8
1362              
1363             =over 4
1364              
1365             =item José Antonio Perez Testa
1366              
1367             =back
1368              
1369             =over 4
1370              
1371             =item Frank Schophuizen
1372              
1373             =back
1374              
1375             =over 4
1376              
1377             =item Zhenyi Zhou
1378              
1379             =back
1380              
1381             =over 4
1382              
1383             =item Roy Lyons
1384              
1385             =back
1386              
1387             =over 4
1388              
1389             =item Neil Hemingway
1390              
1391             =back
1392              
1393             =over 4
1394              
1395             =item Andreas Mager
1396              
1397             =back
1398              
1399              
1400             =head1 COPYRIGHT AND LICENSE
1401              
1402             This software is copyright (c) 2016 by Polyvore, Inc.
1403              
1404             This is free software; you can redistribute it and/or modify it under
1405             the same terms as the Perl 5 programming language system itself.
1406              
1407             =cut
1408              
1409             1;