File Coverage

blib/lib/JIRA/Client.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             package JIRA::Client;
2             # ABSTRACT: (DEPRECATED) Extended interface to JIRA's SOAP API
3             $JIRA::Client::VERSION = '0.43';
4 2     2   54125 use strict;
  2         4  
  2         109  
5 2     2   13 use warnings;
  2         5  
  2         86  
6              
7 2     2   12 use Carp;
  2         10  
  2         228  
8 2     2   1458 use Data::Util qw(:check);
  2         2091  
  2         430  
9 2     2   1245 use SOAP::Lite;
  0            
  0            
10              
11              
12             sub new {
13             my ($class, @args) = @_;
14              
15             my $args;
16              
17             if (@args == 1) {
18             $args = shift @args;
19             is_hash_ref($args) or croak "$class::new sole argument must be a hash-ref.\n";
20             foreach my $arg (qw/baseurl user password/) {
21             exists $args->{$arg}
22             or croak "Missing $arg key to $class::new hash argument.\n";
23             }
24             $args->{soapargs} = [] unless exists $args->{soapargs};
25             } elsif (@args >= 3) {
26             my $baseurl = shift @args;
27             my $user = shift @args;
28             my $password = shift @args;
29             $args = {
30             baseurl => $baseurl,
31             user => $user,
32             password => $password,
33             soapargs => \@args,
34             };
35             } else {
36             croak "Invalid number of arguments to $class::new.\n";
37             }
38              
39             $args->{wsdl} = '/rpc/soap/jirasoapservice-v2?wsdl' unless exists $args->{wsdl};
40              
41             my $url = $args->{baseurl};
42             $url =~ s{/$}{}; # clean trailing slash
43             $url .= $args->{wsdl};
44              
45             my $soap = SOAP::Lite->proxy($url, @{$args->{soapargs}});
46              
47             # Make all scalars be encoded as strings by default.
48             $soap->typelookup({default => [0, sub {1}, 'as_string']});
49              
50             my $auth = $soap->login($args->{user}, $args->{password});
51             croak $auth->faultcode(), ', ', $auth->faultstring()
52             if defined $auth->fault();
53              
54             my $auth_result = $auth->result()
55             or croak "Unknown error while connecting to JIRA. Please, check the URL.\n";
56              
57             my $self = {
58             soap => $soap,
59             auth => $auth_result,
60             iter => undef,
61             cache => {
62             components => {}, # project_key => {name => RemoteComponent}
63             versions => {}, # project_key => {name => RemoteVersion}
64             },
65             };
66              
67             return bless $self, $class;
68             }
69              
70             # This empty DESTROY is necessary because we're using AUTOLOAD.
71             # http://www.perlmonks.org/?node_id=93045
72             sub DESTROY { }
73              
74             # The issue "https://jira.atlassian.com/browse/JRA-12300" explains why
75             # some fields in JIRA have nonintuitive names. Here we map them.
76              
77             my %JRA12300 = (
78             affectsVersions => 'versions',
79             type => 'issuetype',
80             );
81              
82             my %JRA12300_backwards = reverse %JRA12300;
83              
84             # These are some helper functions to convert names into ids.
85              
86             sub _convert_type {
87             my ($self, $type) = @_;
88             if ($type =~ /\D/) {
89             my $types = $self->get_issue_types();
90             return $types->{$type}{id} if exists $types->{$type};
91              
92             $types = $self->get_subtask_issue_types();
93             return $types->{$type}{id} if exists $types->{$type};
94              
95             croak "There is no issue type called '$type'.\n";
96             }
97             return $type;
98             }
99              
100             sub _convert_priority {
101             my ($self, $prio) = @_;
102             if ($prio =~ /\D/) {
103             my $prios = $self->get_priorities();
104             croak "There is no priority called '$prio'.\n"
105             unless exists $prios->{$prio};
106             return $prios->{$prio}{id};
107             }
108             return $prio;
109             }
110              
111             sub _convert_resolution {
112             my ($self, $resolution) = @_;
113             if ($resolution =~ /\D/) {
114             my $resolutions = $self->get_resolutions();
115             croak "There is no resolution called '$resolution'.\n"
116             unless exists $resolutions->{$resolution};
117             return $resolutions->{$resolution}{id};
118             }
119             return $resolution;
120             }
121              
122             sub _convert_security_level {
123             my ($self, $seclevel, $project) = @_;
124             if ($seclevel =~ /\D/) {
125             my $seclevels = $self->get_security_levels($project);
126             croak "There is no security level called '$seclevel'.\n"
127             unless exists $seclevels->{$seclevel};
128             return $seclevels->{$seclevel}{id};
129             }
130             return $seclevel;
131             }
132              
133             # This routine receives an array with a list of $components specified
134             # by RemoteComponent objects, names, and ids. It returns an array of
135             # RemoteComponent objects.
136              
137             sub _convert_components {
138             my ($self, $components, $project) = @_;
139             is_array_ref($components) or croak "The 'components' value must be an ARRAY ref.\n";
140             my @converted;
141             my $pcomponents; # project components
142             foreach my $component (@{$components}) {
143             if (is_instance($component => 'RemoteComponent')) {
144             push @converted, $component;
145             } elsif (is_integer($component)) {
146             push @converted, RemoteComponent->new($component);
147             } else {
148             # It's a component name. Let us convert it into its id.
149             croak "Cannot convert component names because I don't know for which project.\n"
150             unless $project;
151             $pcomponents = $self->get_components($project) unless defined $pcomponents;
152             croak "There is no component called '$component'.\n"
153             unless exists $pcomponents->{$component};
154             push @converted, RemoteComponent->new($pcomponents->{$component}{id});
155             }
156             }
157             return \@converted;
158             }
159              
160             # This routine receives an array with a list of $versions specified by
161             # RemoteVersion objects, names, and ids. It returns an array of
162             # RemoteVersion objects.
163              
164             sub _convert_versions {
165             my ($self, $versions, $project) = @_;
166             is_array_ref($versions) or croak "The '$versions' value must be an ARRAY ref.\n";
167             my @converted;
168             my $pversions; # project versions
169             foreach my $version (@{$versions}) {
170             if (is_instance($version => 'RemoteVersion')) {
171             push @converted, $version;
172             } elsif (is_integer($version)) {
173             push @converted, RemoteVersion->new($version);
174             } else {
175             # It is a version name. Let us convert it into its id.
176             croak "Cannot convert version names because I don't know for which project.\n"
177             unless $project;
178             $pversions = $self->get_versions($project) unless defined $pversions;
179             croak "There is no version called '$version'.\n"
180             unless exists $pversions->{$version};
181             push @converted, RemoteVersion->new($pversions->{$version}{id});
182             }
183             }
184             return \@converted;
185             }
186              
187             # This routine returns a duedate as a SOAP::Data object with type
188             # 'date'. It can generate this from a DateTime object or from a string
189             # in the format YYYY-MM-DD.
190              
191             sub _convert_duedate {
192             my ($self, $duedate) = @_;
193             if (is_instance($duedate => 'DateTime')) {
194             return SOAP::Data->type(date => $duedate->strftime('%F'));
195             } elsif (is_string($duedate)) {
196             if (my ($year, $month, $day) = ($duedate =~ /^(\d{4})-(\d{2})-(\d{2})/)) {
197             $month >= 1 and $month <= 12
198             or croak "Invalid duedate ($duedate).\n";
199             return SOAP::Data->type(date => $duedate);
200             }
201             }
202             return $duedate;
203             }
204              
205             # This routine receives a hash mapping custom field's ids to
206             # values. The ids can be specified by their real id or by their id's
207             # numeric suffix (as the 1000 in 'customfield_1000'). Scalar values
208             # are substituted by references to arrays containing the original
209             # value. The routine returns a hash-ref to another hash with converted
210             # keys and values.
211              
212             sub _convert_custom_fields {
213             my ($self, $custom_fields) = @_;
214             is_hash_ref($custom_fields) or croak "The 'custom_fields' value must be a HASH ref.\n";
215             my %converted;
216             while (my ($id, $values) = each %$custom_fields) {
217             my $realid = $id;
218             unless ($realid =~ /^customfield_\d+$/) {
219             my $cfs = $self->get_custom_fields();
220             croak "Can't find custom field named '$id'.\n"
221             unless exists $cfs->{$id};
222             $realid = $cfs->{$id}{id};
223             }
224              
225             # Custom field values must be specified as ARRAYs but we allow for some short-cuts.
226             if (is_value($values)) {
227             $converted{$realid} = [$values];
228             } elsif (is_array_ref($values)) {
229             $converted{$realid} = $values;
230             } elsif (is_hash_ref($values)) {
231             # This is a short-cut for a Cascading select field, which
232             # must be specified like this: http://tinyurl.com/2bmthoa
233             # The short-cut requires a HASH where each cascading level
234             # is indexed by its level number, starting at zero.
235             foreach my $level (sort {$a <=> $b} keys %$values) {
236             my $level_values = $values->{$level};
237             $level_values = [$level_values] unless ref $level_values;
238             if ($level eq '0') {
239             # The first level doesn't have a colon
240             $converted{$realid} = $level_values
241             } elsif ($level =~ /^\d+$/) {
242             $converted{"$realid:$level"} = $level_values;
243             } else {
244             croak "Invalid cascading field values level spec ($level). It must be a natural number.\n";
245             }
246             }
247             } else {
248             croak "Custom field '$id' got a '", ref($values), "' reference as a value.\nValues can only be specified as scalars, ARRAYs, or HASHes though.\n";
249             }
250             }
251             return \%converted;
252             }
253              
254             my %_converters = (
255             affectsVersions => \&_convert_versions,
256             components => \&_convert_components,
257             custom_fields => \&_convert_custom_fields,
258             duedate => \&_convert_duedate,
259             fixVersions => \&_convert_versions,
260             priority => \&_convert_priority,
261             resolution => \&_convert_resolution,
262             type => \&_convert_type,
263             );
264              
265             # Accept both names for fields with duplicate names.
266             foreach my $field (keys %JRA12300) {
267             $_converters{$JRA12300{$field}} = $_converters{$field};
268             }
269              
270             # This routine applies all the previous conversions to the $params
271             # hash. It returns a reference another hash with converted keys and
272             # values, which is the base for invoking the methods createIssue,
273             # UpdateIssue, and progressWorkflowAction.
274              
275             sub _convert_params {
276             my ($self, $params, $project) = @_;
277              
278             my %converted;
279              
280             # Convert fields' values
281             while (my ($field, $value) = each %$params) {
282             $converted{$field} =
283             exists $_converters{$field}
284             ? $_converters{$field}->($self, $value, $project)
285             : $value;
286             }
287              
288             return \%converted;
289             }
290              
291             # This routine gets a hash produced by _convert_params and flatens in
292             # place its Component, Version, and custom_fields fields. It also
293             # converts the hash's key according with the %JRA12300 table. It goes
294             # a step further before invoking the methods UpdateIssue and
295             # progressWorkflowAction.
296              
297             sub _flaten_components_and_versions {
298             my ($params) = @_;
299              
300             # Flaten Component and Version fields
301             for my $field (grep {exists $params->{$_}} qw/components affectsVersions fixVersions/) {
302             $params->{$field} = [map {$_->{id}} @{$params->{$field}}];
303             }
304              
305             # Flaten the customFieldValues field
306             if (my $custom_fields = delete $params->{custom_fields}) {
307             while (my ($id, $values) = each %$custom_fields) {
308             $params->{$id} = $values;
309             }
310             }
311              
312             # Due to a bug in JIRA we have to substitute the names of some fields.
313             foreach my $field (grep {exists $params->{$_}} keys %JRA12300) {
314             $params->{$JRA12300{$field}} = delete $params->{$field};
315             }
316              
317             return;
318             }
319              
320              
321             sub create_issue
322             {
323             my ($self, $params, $seclevel) = @_;
324             is_hash_ref($params) or croak "create_issue's requires a HASH-ref argument.\n";
325             for my $field (qw/project summary type/) {
326             croak "create_issue's HASH ref must define a '$field'.\n"
327             unless exists $params->{$field};
328             }
329              
330             $params = $self->_convert_params($params, $params->{project});
331              
332             # Substitute customFieldValues array for custom_fields hash
333             if (my $cfs = delete $params->{custom_fields}) {
334             $params->{customFieldValues} = [map {RemoteCustomFieldValue->new($_, $cfs->{$_})} keys %$cfs];
335             }
336              
337             if (my $parent = delete $params->{parent}) {
338             if (defined $seclevel) {
339             return $self->createIssueWithParentWithSecurityLevel($params, $parent, _convert_security_level($self, $seclevel, $params->{project}));
340             } else {
341             return $self->createIssueWithParent($params, $parent);
342             }
343             } else {
344             if (defined $seclevel) {
345             return $self->createIssueWithSecurityLevel($params, _convert_security_level($self, $seclevel, $params->{project}));
346             } else {
347             return $self->createIssue($params);
348             }
349             }
350             }
351              
352              
353             sub update_issue
354             {
355             my ($self, $issue, $params) = @_;
356             my $key;
357             if (is_instance($issue => 'RemoteIssue')) {
358             $key = $issue->{key};
359             } else {
360             $key = $issue;
361             $issue = $self->getIssue($key);
362             }
363              
364             is_hash_ref($params) or croak "update_issue second argument must be a HASH ref.\n";
365              
366             my ($project) = ($key =~ /^([^-]+)/);
367              
368             $params = $self->_convert_params($params, $project);
369              
370             _flaten_components_and_versions($params);
371              
372             return $self->updateIssue($key, $params);
373             }
374              
375              
376             sub get_issue_types {
377             my ($self) = @_;
378             $self->{cache}{issue_types} ||= {map {$_->{name} => $_} @{$self->getIssueTypes()}};
379             return $self->{cache}{issue_types};
380             }
381              
382              
383             sub get_subtask_issue_types {
384             my ($self) = @_;
385             $self->{cache}{subtask_issue_types} ||= {map {$_->{name} => $_} @{$self->getSubTaskIssueTypes()}};
386             return $self->{cache}{subtask_issue_types};
387             }
388              
389              
390             sub get_statuses {
391             my ($self) = @_;
392             $self->{cache}{statuses} ||= {map {$_->{name} => $_} @{$self->getStatuses()}};
393             return $self->{cache}{statuses};
394             }
395              
396              
397             sub get_priorities {
398             my ($self) = @_;
399             $self->{cache}{priorities} ||= {map {$_->{name} => $_} @{$self->getPriorities()}};
400             return $self->{cache}{priorities};
401             }
402              
403              
404             sub get_resolutions {
405             my ($self) = @_;
406             $self->{cache}{resolutions} ||= {map {$_->{name} => $_} @{$self->getResolutions()}};
407             return $self->{cache}{resolutions};
408             }
409              
410              
411             sub get_security_levels {
412             my ($self, $project_key) = @_;
413             $self->{cache}{seclevels}{$project_key} ||= {map {$_->{name} => $_} @{$self->getSecurityLevels($project_key)}};
414             return $self->{cache}{seclevels}{$project_key};
415             }
416              
417              
418             sub get_custom_fields {
419             my ($self) = @_;
420             $self->{cache}{custom_fields} ||= {map {$_->{name} => $_} @{$self->getCustomFields()}};
421             return $self->{cache}{custom_fields};
422             }
423              
424              
425             sub set_custom_fields {
426             my ($self, $cfs) = @_;
427             $self->{cache}{custom_fields} = $cfs;
428             return;
429             }
430              
431              
432             sub get_components {
433             my ($self, $project_key) = @_;
434             $self->{cache}{components}{$project_key} ||= {map {$_->{name} => $_} @{$self->getComponents($project_key)}};
435             return $self->{cache}{components}{$project_key};
436             }
437              
438              
439             sub get_versions {
440             my ($self, $project_key) = @_;
441             $self->{cache}{versions}{$project_key} ||= {map {$_->{name} => $_} @{$self->getVersions($project_key)}};
442             return $self->{cache}{versions}{$project_key};
443             }
444              
445              
446             sub get_favourite_filters {
447             my ($self) = @_;
448             $self->{cache}{filters} ||= {map {$_->{name} => $_} @{$self->getFavouriteFilters()}};
449             return $self->{cache}{filters};
450             }
451              
452              
453             sub set_filter_iterator {
454             my ($self, $filter, $cache_size) = @_;
455              
456             if ($filter =~ /\D/) {
457             my $filters = $self->getSavedFilters();
458             foreach my $f (@$filters) {
459             if ($f->{name} eq $filter) {
460             $filter = $f->{id};
461             last;
462             }
463             }
464             croak "Can't find filter '$filter'\n" if $filter =~ /\D/;
465             }
466              
467             if ($cache_size) {
468             croak "set_filter_iterator's second arg must be a number ($cache_size).\n"
469             if $cache_size =~ /\D/;
470             }
471              
472             $self->{iter} = {
473             id => $filter,
474             offset => 0, # offset to be used in the next call to getIssuesFromFilterWithLimit
475             issues => [], # issues returned by the last call to getIssuesFromFilterWithLimit
476             size => $cache_size || 128,
477             };
478              
479             return;
480             }
481              
482              
483             sub next_issue {
484             my ($self) = @_;
485             defined $self->{iter}
486             or croak "You must call setFilterIterator before calling nextIssue\n";
487             my $iter = $self->{iter};
488             if (@{$iter->{issues}} == 0) {
489             if ($iter->{id}) {
490             my $issues = eval {$self->getIssuesFromFilterWithLimit($iter->{id}, $iter->{offset}, $iter->{size})};
491             if ($@) {
492             # The getIssuesFromFilterWithLimit appeared in JIRA
493             # 3.13.4. Before that we had to use the unsafe
494             # getIssuesFromFilter. Here we detect that we're talking
495             # with an old JIRA and resort to the deprecated method
496             # instead.
497             croak $@ unless $@ =~ /No such operation/;
498             $iter->{issues} = $self->getIssuesFromFilter($iter->{id});
499             $iter->{id} = undef;
500             }
501             elsif (@$issues) {
502             $iter->{offset} += @$issues;
503             $iter->{issues} = $issues;
504             }
505             else {
506             $self->{iter} = undef;
507             return;
508             }
509             }
510             else {
511             return;
512             }
513             }
514             return shift @{$iter->{issues}};
515             }
516              
517              
518             sub progress_workflow_action_safely {
519             my ($self, $issue, $action, $params) = @_;
520             my $key;
521             if (is_instance($issue => 'RemoteIssue')) {
522             $key = $issue->{key};
523             } else {
524             $key = $issue;
525             $issue = undef;
526             }
527             $params = {} unless defined $params;
528             is_hash_ref($params) or croak "progress_workflow_action_safely's third arg must be a HASH-ref\n";
529              
530             # Grok the action id if it's not a number
531             if ($action =~ /\D/) {
532             my @available_actions = @{$self->getAvailableActions($key)};
533             my @named_actions = grep {$action eq $_->{name}} @available_actions;
534             if (@named_actions) {
535             $action = $named_actions[0]->{id};
536             } else {
537             croak "Unavailable action ($action).\n";
538             }
539             }
540              
541             # Make sure $params contains all the fields that are present in
542             # the action screen.
543             my @fields = @{$self->getFieldsForAction($key, $action)};
544             foreach my $id (map {$_->{id}} @fields) {
545             # Due to a bug in JIRA we have to substitute the names of some fields.
546             $id = $JRA12300_backwards{$id} if $JRA12300_backwards{$id};
547              
548             next if exists $params->{$id};
549              
550             $issue = $self->getIssue($key) unless defined $issue;
551             if (exists $issue->{$id}) {
552             $params->{$id} = $issue->{$id} if defined $issue->{$id};
553             }
554             # NOTE: It's not a problem if we can't find a missing
555             # parameter in the issue. It will simply stay undefined.
556             }
557              
558             my ($project) = ($key =~ /^([^-]+)/);
559              
560             $params = $self->_convert_params($params, $project);
561              
562             _flaten_components_and_versions($params);
563              
564             return $self->progressWorkflowAction($key, $action, $params);
565             }
566              
567              
568             sub get_issue_custom_field_values {
569             my ($self, $issue, @cfs) = @_;
570             my @values;
571             my $cfs;
572             CUSTOM_FIELD:
573             foreach my $cf (@cfs) {
574             unless ($cf =~ /^customfield_\d+$/) {
575             $cfs = $self->get_custom_fields() unless defined $cfs;
576             croak "Can't find custom field named '$cf'.\n"
577             unless exists $cfs->{$cf};
578             $cf = $cfs->{$cf}{id};
579             }
580             foreach my $rcfv (@{$issue->{customFieldValues}}) {
581             if ($rcfv->{customfieldId} eq $cf) {
582             push @values, $rcfv->{values};
583             next CUSTOM_FIELD;
584             }
585             }
586             push @values, undef; # unset custom field
587             }
588             return wantarray ? @values : \@values;
589             }
590              
591              
592             sub attach_files_to_issue {
593             my ($self, $issue, @files) = @_;
594              
595             # First we process the @files specification. Filenames are pushed
596             # in @filenames and @attachments will end up with IO objects from
597             # which the file contents are going to be read later.
598              
599             my (@filenames, @attachments);
600              
601             for my $file (@files) {
602             if (is_string($file)) {
603             require File::Basename;
604             push @filenames, File::Basename::basename($file);
605             open my $fh, '<:raw', $file
606             or croak "Can't open $file: $!\n";
607             push @attachments, $fh;
608             close $fh;
609             } elsif (is_hash_ref($file)) {
610             while (my ($name, $contents) = each %$file) {
611             push @filenames, $name;
612             if (is_string($contents)) {
613             open my $fh, '<:raw', $contents
614             or croak "Can't open $contents: $!\n";
615             push @attachments, $fh;
616             close $fh;
617             } elsif (is_glob_ref($contents)
618             || is_instance($contents => 'IO::File')
619             || is_instance($contents => 'FileHandle')) {
620             push @attachments, $contents;
621             } else {
622             croak "Invalid content specification for file $name.\n";
623             }
624             }
625             } else {
626             croak "Files must be specified by STRINGs or HASHes, not by " . ref($file) . "s\n";
627             }
628             }
629              
630             # Now we have to read all file contents and encode them to Base64.
631              
632             require MIME::Base64;
633             for my $i (0 .. $#attachments) {
634             my $fh = $attachments[$i];
635             my $attachment = '';
636             my $chars_read;
637             while ($chars_read = read $fh, my $buf, 57*72) {
638             $attachment .= MIME::Base64::encode_base64($buf);
639             }
640             defined $chars_read
641             or croak "Error reading '$filenames[$i]': $!\n";
642             length $attachment
643             or croak "Can't attach empty file '$filenames[$i]'\n";
644             $attachments[$i] = $attachment;
645             }
646              
647             return $self->addBase64EncodedAttachmentsToIssue($issue, \@filenames, \@attachments);
648             }
649              
650              
651             sub attach_strings_to_issue {
652             my ($self, $issue, $hash) = @_;
653              
654             require MIME::Base64;
655              
656             my (@filenames, @attachments);
657              
658             while (my ($filename, $contents) = each %$hash) {
659             push @filenames, $filename;
660             push @attachments, MIME::Base64::encode_base64($contents);
661             }
662              
663             return $self->addBase64EncodedAttachmentsToIssue($issue, \@filenames, \@attachments);
664             }
665              
666              
667             sub filter_issues_unsorted {
668             my ($self, $filter, $limit) = @_;
669              
670             $filter =~ s/^\s*"?//;
671             $filter =~ s/"?\s*$//;
672              
673             if ($filter =~ /^(?:[A-Z]+-\d+\s+)*[A-Z]+-\d+$/i) {
674             # space separated key list
675              
676             # Let's construct a JQL query in the form "issuekey IN (...)" to
677             # pass to getIssuesFromJqlSearch.
678              
679             my %keys = map {($_ => undef)} split / /, $filter; # discard duplicates
680             my @keys = keys %keys;
681              
682             # If the list is too big we split it up and invoke
683             # getIssuesFromJqlSearch several times.
684             my @issues;
685             while (my @subkeys = splice(@keys, 0, 128)) {
686             my $jql = 'issuekey IN (' . join(',', @subkeys) . ')';
687             push @issues, @{$self->getIssuesFromJqlSearch($jql, 1000)};
688             }
689             return @issues;
690             } elsif ($filter =~ /^[\w-]+$/i) {
691             # saved filter
692             return @{$self->getIssuesFromFilterWithLimit($filter, 0, $limit || 1000)};
693             } else {
694             # JQL filter
695             return @{$self->getIssuesFromJqlSearch($filter, $limit || 1000)};
696             }
697             }
698              
699              
700             sub filter_issues {
701             my ($self, $filter, $limit) = @_;
702              
703             # Order the issues by project key and then by numeric value using
704             # a Schwartzian transform.
705             return
706             map {$_->[2]}
707             sort {$a->[0] cmp $b->[0] or $a->[1] <=> $b->[1]}
708             map {my ($p, $n) = ($_->{key} =~ /([A-Z]+)-(\d+)/); [$p, $n, $_]}
709             filter_issues_unsorted($self, $filter, $limit);
710             }
711              
712              
713             ## no critic (Modules::ProhibitMultiplePackages)
714              
715             package RemoteFieldValue;
716             $RemoteFieldValue::VERSION = '0.43';
717             sub new {
718             my ($class, $id, $values) = @_;
719              
720             # Due to a bug in JIRA we have to substitute the names of some fields.
721             $id = $JRA12300{$id} if exists $JRA12300{$id};
722              
723             $values = [$values] unless ref $values;
724             return bless({id => $id, values => $values}, $class);
725             }
726              
727              
728             package RemoteCustomFieldValue;
729             $RemoteCustomFieldValue::VERSION = '0.43';
730             sub new {
731             my ($class, $id, $values) = @_;
732              
733             $values = [$values] unless ref $values;
734             return bless({customfieldId => $id, key => undef, values => $values} => $class);
735             }
736              
737              
738             package RemoteComponent;
739             $RemoteComponent::VERSION = '0.43';
740             sub new {
741             my ($class, $id, $name) = @_;
742             my $o = bless({id => $id}, $class);
743             $o->{name} = $name if $name;
744             return $o;
745             }
746              
747              
748             package RemoteVersion;
749             $RemoteVersion::VERSION = '0.43';
750             sub new {
751             my ($class, $id, $name) = @_;
752             my $o = bless({id => $id}, $class);
753             $o->{name} = $name if $name;
754             return $o;
755             }
756              
757             package JIRA::Client;
758              
759             # Almost all of the JIRA API parameters are strings. The %typeof hash
760             # specifies the exceptions. It maps a method name to a hash mapping a
761             # parameter position to its type. (The parameter position is
762             # zero-based, after the authentication token.
763              
764             my %typeof = (
765             addActorsToProjectRole => {1 => \&_cast_remote_project_role},
766             addAttachmentsToIssue => \&_cast_attachments,
767             addBase64EncodedAttachmentsToIssue => \&_cast_base64encodedattachments,
768             addComment => {0 => \&_cast_issue_key, 1 => \&_cast_remote_comment},
769             addDefaultActorsToProjectRole => {1 => \&_cast_remote_project_role},
770             # addPermissionTo
771             # addUserToGroup
772             # addVersion
773             addWorklogAndAutoAdjustRemainingEstimate => {0 => \&_cast_issue_key},
774             addWorklogAndRetainRemainingEstimate => {0 => \&_cast_issue_key},
775             addWorklogWithNewRemainingEstimate => {0 => \&_cast_issue_key},
776             archiveVersion => {2 => 'boolean'},
777             # createGroup
778             # createIssue
779             createIssueWithParent => {1 => \&_cast_issue_key},
780             createIssueWithParentWithSecurityLevel => {1 => \&_cast_issue_key, 2 => 'long'},
781             createIssueWithSecurityLevel => {1 => 'long'},
782             # createPermissionScheme
783             # createProject
784             # createProjectFromObject
785             createProjectRole => {0 => \&_cast_remote_project_role},
786             # createUser
787             # deleteGroup
788             deleteIssue => {0 => \&_cast_issue_key},
789             # deletePermissionFrom
790             # deletePermissionScheme
791             # deleteProject
792             deleteProjectAvatar => {0 => 'long'},
793             deleteProjectRole => {0 => \&_cast_remote_project_role, 1 => 'boolean'},
794             # deleteUser
795             # deleteWorklogAndAutoAdjustRemainingEstimate
796             # deleteWorklogAndRetainRemainingEstimate
797             # deleteWorklogWithNewRemainingEstimate
798             # editComment
799             # getAllPermissions
800             getAssociatedNotificationSchemes => {0 => \&_cast_remote_project_role},
801             getAssociatedPermissionSchemes => {0 => \&_cast_remote_project_role},
802             getAttachmentsFromIssue => {0 => \&_cast_issue_key},
803             getAvailableActions => {0 => \&_cast_issue_key},
804             getComment => {0 => 'long'},
805             getComments => {0 => \&_cast_issue_key},
806             # getComponents
807             # getConfiguration
808             # getCustomFields
809             getDefaultRoleActors => {0 => \&_cast_remote_project_role},
810             # getFavouriteFilters
811             getFieldsForAction => {0 => \&_cast_issue_key},
812             getFieldsForCreate => {1 => 'long'},
813             getFieldsForEdit => {0 => \&_cast_issue_key},
814             # getGroup
815             getIssue => {0 => \&_cast_issue_key},
816             # getIssueById
817             getIssueCountForFilter => {0 => \&_cast_filter_name_to_id},
818             getIssuesFromFilter => {0 => \&_cast_filter_name_to_id},
819             getIssuesFromFilterWithLimit => {0 => \&_cast_filter_name_to_id, 1 => 'int', 2 => 'int'},
820             getIssuesFromJqlSearch => {1 => 'int'},
821             # getIssuesFromTextSearch
822             getIssuesFromTextSearchWithLimit => {1 => 'int', 2 => 'int'},
823             getIssuesFromTextSearchWithProject => {2 => 'int'},
824             # getIssueTypes
825             # getIssueTypesForProject
826             # getNotificationSchemes
827             # getPermissionSchemes
828             # getPriorities
829             # getProjectAvatar
830             getProjectAvatars => {1 => 'boolean'},
831             getProjectById => {0 => 'long'},
832             # getProjectByKey
833             getProjectRole => {0 => 'long'},
834             getProjectRoleActors => {0 => \&_cast_remote_project_role},
835             # getProjectRoles
836             # getProjectsNoSchemes
837             getProjectWithSchemesById => {0 => 'long'},
838             getResolutionDateById => {0 => 'long'},
839             getResolutionDateByKey => {0 => \&_cast_issue_key},
840             # getResolutions
841             # getSavedFilters
842             getSecurityLevel => {0 => \&_cast_issue_key},
843             # getSecurityLevels
844             # getSecuritySchemes
845             # getServerInfo
846             # getStatuses
847             # getSubTaskIssueTypes
848             # getSubTaskIssueTypesForProject
849             # getUser
850             # getVersions
851             getWorklogs => {0 => \&_cast_issue_key},
852             hasPermissionToCreateWorklog => {0 => \&_cast_issue_key},
853             # hasPermissionToDeleteWorklog
854             # hasPermissionToEditComment
855             # hasPermissionToUpdateWorklog
856             # isProjectRoleNameUnique
857             # login ##NOT USED##
858             # logout ##NOT USED##
859             progressWorkflowAction => {0 => \&_cast_issue_key, 2 => \&_cast_remote_field_values},
860             # refreshCustomFields
861             # releaseVersion
862             removeActorsFromProjectRole => {1 => \&_cast_remote_project_role},
863             # removeAllRoleActorsByNameAndType
864             # removeAllRoleActorsByProject
865             removeDefaultActorsFromProjectRole => {1 => \&_cast_remote_project_role},
866             # removeUserFromGroup
867             # setNewProjectAvatar
868             setProjectAvatar => {1 => 'long'},
869             # setUserPassword
870             # updateGroup
871             updateIssue => {0 => \&_cast_issue_key, 1 => \&_cast_remote_field_values},
872             # updateProject
873             updateProjectRole => {0 => \&_cast_remote_project_role},
874             # updateUser
875             # updateWorklogAndAutoAdjustRemainingEstimate
876             # updateWorklogAndRetainRemainingEstimate
877             # updateWorklogWithNewRemainingEstimate
878             );
879              
880             sub _cast_issue_key {
881             my ($self, $issue) = @_;
882             return ref $issue ? $issue->{key} : $issue;
883             }
884              
885             sub _cast_remote_comment {
886             my ($self, $arg) = @_;
887             return ref $arg ? $arg : bless({body => $arg} => 'RemoteComment');
888             }
889              
890             sub _cast_filter_name_to_id {
891             my ($self, $arg) = @_;
892             is_string($arg) or croak "Filter arg must be a string.\n";
893             return $arg unless $arg =~ /\D/;
894             my $filters = $self->get_favourite_filters();
895             exists $filters->{$arg} or croak "Unknown filter: $arg\n";
896             return $filters->{$arg}{id};
897             }
898              
899             sub _cast_remote_field_values {
900             my ($self, $arg) = @_;
901             return is_hash_ref($arg) ? [map {RemoteFieldValue->new($_, $arg->{$_})} keys %$arg] : $arg;
902             }
903              
904             sub _cast_remote_project_role {
905             my ($self, $arg) = @_;
906             if (is_instance($arg => 'RemoteProjectRole') && exists $arg->{id} && is_string($arg->{id})) {
907             $arg->{id} = SOAP::Data->type(long => $arg->{id});
908             }
909             return $arg;
910             }
911              
912             sub _cast_attachments {
913             my ($self, $method, $args) = @_;
914             # The addAttachmentsToIssue method is deprecated and requires too
915             # much overhead to pass the file contents over the wire. Here we
916             # convert the arguments to call the newer
917             # addBase64EncodedAttachmentsToIssue method instead.
918             require MIME::Base64;
919             for my $content (@{$args->[2]}) {
920             $content = MIME::Base64::encode_base64($content);
921             }
922             $$method = 'addBase64EncodedAttachmentsToIssue';
923             _cast_base64encodedattachments($self, $method, $args);
924             return;
925             }
926              
927             sub _cast_base64encodedattachments {
928             my ($self, $method, $args) = @_;
929             $args->[0] = _cast_issue_key($self, $args->[0]);
930             # We have to set the names of the arrays and of its elements
931             # because the default naming isn't properly understood by JIRA.
932             for my $i (1 .. 2) {
933             $args->[$i] = SOAP::Data->name(
934             "array$i",
935             [map {SOAP::Data->name("elem$i", $_)} @{$args->[$i]}],
936             );
937             }
938             return;
939             }
940              
941             # All methods follow the same call convention, which makes it easy to
942             # implement them all with an AUTOLOAD.
943              
944             our $AUTOLOAD;
945             sub AUTOLOAD {
946             my ($self, @args) = @_;
947             (my $method = $AUTOLOAD) =~ s/.*:://;
948              
949             # Perform any non-default type coersion
950             if (my $typeof = $typeof{$method}) {
951             if (is_hash_ref($typeof)) {
952             while (my ($i, $type) = each %$typeof) {
953             if (is_code_ref($type)) {
954             $args[$i] = $type->($self, $args[$i]);
955             } elsif (is_value($args[$i])) {
956             $args[$i] = SOAP::Data->type($type => $args[$i]);
957             } elsif (is_array_ref($args[$i])) {
958             foreach (@{$args[$i]}) {
959             $_ = SOAP::Data->type($type => $_);
960             }
961             } elsif (is_hash_ref($args[$i])) {
962             foreach (values %{$args[$i]}) {
963             $_ = SOAP::Data->type($type => $_);
964             }
965             } else {
966             croak "Can't coerse argument $i of method $AUTOLOAD.\n";
967             }
968             }
969             } elsif (is_code_ref($typeof)) {
970             $typeof->($self, \$method, \@args);
971             }
972             }
973              
974             my $call = $self->{soap}->call($method, $self->{auth}, @args);
975             croak $call->faultcode(), ', ', $call->faultstring()
976             if defined $call->fault();
977             return $call->result();
978             }
979              
980              
981             1; # End of JIRA::Client
982              
983             __END__