File Coverage

blib/lib/JIRA/Client/Automated.pm
Criterion Covered Total %
statement 154 348 44.2
branch 27 76 35.5
condition 17 67 25.3
subroutine 24 50 48.0
pod 26 26 100.0
total 248 567 43.7


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