3 =head1 NAME $RCSfile: JIRAUtils.pm,v $
5 Some shared functions dealing with JIRA
13 Andrew DeFaria <Andrew@ClearSCM.com>
21 Fri Mar 12 10:17:44 PST 2004
25 $Date: 2013/05/30 15:48:06 $
31 The following routines are exported:
85 my (@issueLinkTypes, %cache, $jiradb, %findQuery);
88 ao_08d66b_filter_display_conf => [
89 {column => 'user_name'}
91 ao_0c0737_vote_info => [
92 {column => 'user_name'}
94 ao_3a112f_audit_log_entry => [
97 ao_563aee_activity_entity => [
98 {column => 'username'}
100 ao_60db71_auditentry => [
103 ao_60db71_boardadmins => [
106 ao_60db71_rapidview => [
107 {column => 'owner_user_name'}
109 ao_caff30_favourite_issue => [
110 {column => 'user_key'}
113 # {column => 'user_key'},
114 # {column => 'lower_user_name'},
117 {column => 'author_key'}
126 {column => 'oldvalue',
127 condition => 'field = "assignee"'},
128 {column => 'newvalue',
129 condition => 'field = "assignee"'},
132 {column => 'username'},
137 customfieldvalue => [
138 {column => 'stringvalue'},
140 favouriteassociations => [
141 {column => 'username'},
143 # cwd_membership => [
144 # {column => 'child_name'},
145 # {column => 'lower_child_name'},
148 {column => 'author'},
150 filtersubscription => [
151 {column => 'username'},
154 {column => 'author'},
157 {column => 'reporter'},
158 {column => 'assignee'},
161 {column => 'creatorname'},
164 {column => 'user_name'},
168 {column => 'caller'},
172 {column => 'caller'},
178 {column => 'username'},
180 schemepermissions => [
181 {column => 'perm_parameter',
182 condition => 'perm_type = "user"'},
185 {column => 'authorname'},
186 {column => 'username'},
189 {column => 'source_name'},
192 {column => 'username'},
195 {column => 'username'}
198 {column => 'author'},
202 sub _checkDBError ($;$) {
203 my ($msg, $statement) = @_;
205 $statement //= 'Unknown';
208 $main::log->err ('JIRA database not opened!', 1) unless $jiradb;
211 my $dberr = $jiradb->err;
212 my $dberrmsg = $jiradb->errstr;
215 $dberrmsg ||= 'Success';
220 my $function = (caller (1)) [3];
222 $message = "$function: $msg\nError #$dberr: $dberrmsg\n"
223 . "SQL Statement: $statement";
227 $main::log->err ($message, 1) if $dberr;
233 sub openJIRADB (;$$$$) {
234 my ($dbhost, $dbname, $dbuser, $dbpass) = @_;
236 $dbhost //= $main::opts{jiradbhost};
237 $dbname //= 'jiradb';
241 $main::log->msg ("Connecting to JIRA ($dbuser\@$dbhost)...") if $main::log;
243 $jiradb = DBI->connect (
244 "DBI:mysql:$dbname:$dbhost",
252 _checkDBError "Unable to open $dbname ($dbuser\@$dbhost)";
257 sub Connect2JIRA (;$$$) {
258 my ($username, $password, $server) = @_;
262 $opts{username} = $username || 'jira-admin';
263 $opts{password} = $password || $ENV{PASSWORD} || 'jira-admin';
264 $opts{server} = $server || $ENV{JIRA_SERVER} || 'jira-dev';
265 $opts{URL} = "http://$opts{server}/rest/api/latest";
267 $main::log->msg ("Connecting to JIRA ($opts{username}\@$opts{server})") if $main::log;
269 $jira = JIRA::REST->new ($opts{URL}, $opts{username}, $opts{password});
271 # Store username as we might need it (see updateIssueWatchers)
272 $jira->{username} = $opts{username};
278 my ($table, $condition) = @_;
282 $jiradb = openJIRADB unless $jiradb;
285 $statement = "select count(*) from $table where $condition";
287 $statement = "select count(*) from $table";
290 my $sth = $jiradb->prepare ($statement);
292 _checkDBError 'count: Unable to prepare statement', $statement;
296 _checkDBError 'count: Unable to execute statement', $statement;
298 # Get return value, which should be how many message there are
299 my @row = $sth->fetchrow_array;
306 # Retrieve returned value
316 sub addDescription ($$) {
317 my ($issue, $description) = @_;
319 if ($main::opts{exec}) {
320 eval {$jira->PUT ("/issue/$issue", undef, {fields => {description => $description}})};
323 return "Unable to add description\n$@";
325 return 'Description added';
330 sub addJIRAComment ($$) {
331 my ($issue, $comment) = @_;
333 if ($main::opts{exec}) {
334 eval {$jira->POST ("/issue/$issue/comment", undef, { body => $comment })};
337 return "Unable to add comment\n$@";
339 return 'Comment added';
342 return "Would have added comments to $issue";
346 sub blankBugzillaNbr ($) {
349 eval {$jira->PUT ("/issue/$issue", undef, {fields => {'Bugzilla Bug Origin' => ''}})};
350 #eval {$jira->PUT ("/issue/$issue", undef, {fields => {'customfield_10132' => ''}})};
353 return "Unable to blank Bugzilla number$@\n"
359 sub attachmentExists ($$) {
360 my ($issue, $filename) = @_;
362 my $attachments = getIssue ($issue, qw(attachment));
364 for (@{$attachments->{fields}{attachment}}) {
365 return 1 if $filename eq $_->{filename};
371 sub attachFiles2Issue ($@) {
372 my ($issue, @files) = @_;
374 my $status = $jira->attach_files ($issue, @files);
377 } # attachFiles2Issue
379 sub getIssueFromBugID ($) {
385 jql => "\"Bugzilla Bug Origin\" ~ $bugid",
389 eval {$issue = $jira->GET ("/search/", \%query)};
391 my $issueID = $issue->{issues}[0]{key};
393 return $issue->{issues} if @{$issue->{issues}} > 1;
395 } # getIssueFromBugID
398 my ($bugid, %bugmap) = @_;
402 if ($cache{$bugid}) {
403 if ($cache{$bugid} =~ /^\d+/) {
404 # We have a cache hit but the contents here are a bugid. This means we had
405 # searched for the corresponding JIRA issue for this bug before and came
406 # up empty handed. In this situtaion we really have:
407 return "Unable to find a JIRA issue for Bug $bugid";
409 return $cache{$bugid};
416 jql => "\"Bugzilla Bug Origin\" ~ $bugid",
420 eval {$issue = $jira->GET ("/search/", \%query)};
422 my $issueID = $issue->{issues}[0]{key};
424 if (@{$issue->{issues}} > 2) {
425 $main::log->err ("Found more than 2 issues for Bug ID $bugid") if $main::log;
427 return "Found more than 2 issues for Bug ID $bugid";
428 } elsif (@{$issue->{issues}} == 2) {
429 my ($issueNum0, $issueNum1, $projectName0, $projectName1);
431 if ($issue->{issues}[0]{key} =~ /(.*)-(\d+)/) {
436 if ($issue->{issues}[1]{key} =~ /(.*)-(\d+)/) {
441 if ($issueNum0 < $issueNum1) {
442 $issueID = $issue->{issues}[1]{key};
445 # Let's mark them as clones. See if this clone link already exists...
448 for (getIssueLinks ($issueID, 'Cloners')) {
449 my $inwardIssue = $_->{inwardIssue}{key} || '';
450 my $outwardIssue = $_->{outwardIssue}{key} || '';
452 if ("$projectName0-$issueNum0" eq $inwardIssue ||
453 "$projectName0-$issueNum0" eq $outwardIssue ||
454 "$projectName1-$issueNum1" eq $inwardIssue ||
455 "$projectName1-$issueNum1" eq $outwardIssue) {
462 unless ($alreadyCloned) {
463 my $result = linkIssues ("$projectName0-$issueNum0", 'Cloners', "$projectName1-$issueNum1");
465 return $result if $result =~ /Unable to/;
467 $main::log->msg ($result) if $main::log;
472 $main::log->msg ("Found JIRA issue $issueID for Bug $bugid") if $main::log;
474 #$cache{$bugid} = $issueID;
476 #return $cache{$bugid};
479 my $status = $bugmap{$bugid} ? 'Future JIRA Issue'
480 : "Unable to find a JIRA issue for Bug $bugid";
482 # Here we put this bugid into the cache but instead of a the JIRA issue
483 # id we put the bugid. This will stop us from adding up multiple hits on
485 #$cache{$bugid} = $bugid;
491 sub findIssues (;$@) {
492 my ($condition, @fields) = @_;
494 push @fields, '*all' unless @fields;
496 $findQuery{jql} = $condition || '';
497 $findQuery{startAt} = 0;
498 $findQuery{maxResults} = 1;
499 $findQuery{fields} = join ',', @fields;
504 sub getNextIssue () {
507 eval {$result = $jira->GET ('/search/', \%findQuery)};
509 $findQuery{startAt}++;
511 # Move id and key into fields
512 return unless @{$result->{issues}};
514 $result->{issues}[0]{fields}{id} = $result->{issues}[0]{id};
515 $result->{issues}[0]{fields}{key} = $result->{issues}[0]{key};
517 return %{$result->{issues}[0]{fields}};
520 sub getIssues (;$$$@) {
521 my ($condition, $start, $max, @fields) = @_;
523 push @fields, '*all' unless @fields;
525 my ($result, %query);
527 $query{jql} = $condition || '';
528 $query{startAt} = $start || 0;
529 $query{maxResults} = $max || 50;
530 $query{fields} = join ',', @fields;
532 eval {$result = $jira->GET ('/search/', \%query)};
534 # We sometimes get an error here when $result->{issues} is undef.
535 # I suspect this is when the number of issues just happens to be
536 # an even number like on a $query{maxResults} boundry. So when
537 # $result->{issues} is undef we assume it's the last of the issues.
538 # (I should really verify this).
539 if ($result->{issues}) {
540 return @{$result->{issues}};
547 my ($issue, @fields) = @_;
549 my $fields = @fields ? "?fields=" . join ',', @fields : '';
551 return $jira->GET ("/issue/$issue$fields");
554 sub getIssueLinkTypes () {
555 my $issueLinkTypes = $jira->GET ('/issueLinkType/');
557 map {push @issueLinkTypes, $_->{name}} @{$issueLinkTypes->{issueLinkTypes}};
559 return @issueLinkTypes
560 } # getIssueLinkTypes
562 sub linkIssues ($$$) {
563 my ($from, $type, $to) = @_;
565 unless (@issueLinkTypes) {
569 unless (grep {$type eq $_} @issueLinkTypes) {
570 $main::log->err ("Type $type is not a valid issue link type\nValid types include:\n\t"
571 . join "\n\t", @issueLinkTypes) if $main::log;
573 return "Unable to $type link $from -> $to";
587 body => "Link ported as part of the migration from Bugzilla: $from <-> $to",
591 $main::total{'IssueLinks Added'}++;
593 if ($main::opts{exec}) {
594 eval {$jira->POST ("/issueLink", undef, \%link)};
597 return "Unable to $type link $from -> $to\n$@";
599 return "Made $type link $from -> $to";
602 return "Would have $type linked $from -> $to";
606 sub getRemoteLink ($;$) {
607 my ($jiraIssue, $id) = @_;
613 eval {$result = $jira->GET ("/issue/$jiraIssue/remotelink/$id")};
619 if (ref $result eq 'ARRAY') {
620 map {$remoteLinks{$_->{id}} = $_->{object}{title}} @$result;
622 $remoteLinks{$result->{id}} = $result->{object}{title};
625 return \%remoteLinks;
628 sub getRemoteLinks (;$) {
631 $jiradb = openJIRADB unless $jiradb;
633 my $statement = 'select url from remotelink';
635 $statement .= " where url like 'http://bugs%'";
636 $statement .= " and url like '%$bugid'" if $bugid;
637 $statement .= " group by issueid desc";
639 my $sth = $jiradb->prepare ($statement);
641 _checkDBError 'Unable to prepare statement', $statement;
645 _checkDBError 'Unable to execute statement', $statement;
649 while (my $record = $sth->fetchrow_array) {
650 if ($record =~ /(\d+)$/) {
658 sub findRemoteLinkByBugID (;$) {
661 my $condition = 'where issueid = jiraissue.id and jiraissue.project = project.id';
664 $condition .= " and remotelink.url like '%id=$bugid'";
667 $jiradb = openJIRADB unless $jiradb;
669 my $statement = <<"END";
672 concat (project.pkey, '-', issuenum) as issue,
681 my $sth = $jiradb->prepare ($statement);
683 _checkDBError 'Unable to prepare statement', $statement;
687 _checkDBError 'Unable to execute statement', $statement;
691 while (my $row = $sth->fetchrow_hashref) {
692 $row->{bugid} = $bugid;
698 } # findRemoteLinkByBugID
700 sub promoteBug2JIRAIssue ($$$$) {
701 my ($bugid, $jirafrom, $jirato, $relationship) = @_;
703 my $result = linkIssues $jirafrom, $relationship, $jirato;
705 return $result if $result =~ /Unable to link/;
707 $main::log->msg ($result . " (BugID $bugid)") if $main::log;
709 for (@{findRemoteLinkByBugID $bugid}) {
712 $result = removeRemoteLink ($record{issue}, $record{id});
714 # We may not care if we couldn't remove this link because it may have been
715 # removed by a prior pass.
716 return $result if $result =~ /Unable to remove link/;
719 $main::log->msg ($result) unless $result eq '';
724 } # promoteBug2JIRAIssue
726 sub addRemoteLink ($$$) {
727 my ($bugid, $relationship, $jiraIssue) = @_;
729 my $bug = getBug $bugid;
731 # Check to see if this Bug ID already exists on this JIRA Issue, otherwise
732 # JIRA will duplicate it!
733 my $remoteLinks = getRemoteLink $jiraIssue;
735 for (keys %$remoteLinks) {
736 if ($remoteLinks->{$_} =~ /Bug (\d+)/) {
737 return "Bug $bugid is already linked to $jiraIssue" if $bugid == $1;
741 # Note this globalid thing is NOT working! ALl I see is null in the database
743 # globalid => "system=http://bugs.audience.com/show_bug.cgi?id=$bugid",
745 # type => 'Bugzilla',
746 # name => 'Bugzilla',
748 relationship => $relationship,
750 url => "http://bugs.audience.com/show_bug.cgi?id=$bugid",
751 title => "Bug $bugid",
752 summary => $bug->{short_desc},
754 url16x16 => 'http://bugs.audience.local/favicon.png',
755 title => 'Bugzilla Bug',
760 $main::total{'RemoteLink Added'}++;
762 if ($main::opts{exec}) {
763 eval {$jira->POST ("/issue/$jiraIssue/remotelink", undef, \%remoteLink)};
767 return "Would have linked $bugid -> $jiraIssue";
771 sub removeRemoteLink ($;$) {
772 my ($jiraIssue, $id) = @_;
776 my $remoteLinks = getRemoteLink ($jiraIssue, $id);
778 for (keys %$remoteLinks) {
781 $main::total{'RemoteLink Removed'}++;
783 if ($main::opts{exec}) {
784 eval {$result = $jira->DELETE ("/issue/$jiraIssue/remotelink/$_")};
787 return "Unable to remove remotelink $jiraIssue ($id)\n$@" if $@;
791 if ($remoteLinks->{$_} =~ /(\d+)/) {
792 return "Removed remote link $jiraIssue (Bug ID $1)";
796 $main::total{'Remote Links Removed'}++;
798 if ($remoteLinks->{$_} =~ /(\d+)/) {
799 return "Would have removed remote link $jiraIssue (Bug ID $1)";
805 sub getIssueLinks ($;$) {
806 my ($issue, $type) = @_;
808 my @links = getIssue ($issue, ('issuelinks'));
812 for (@{$links[0]->{fields}{issuelinks}}) {
815 next if ($type && $type ne $issueLink{type}{name});
817 push @issueLinks, \%issueLink;
823 sub getIssueWatchers ($) {
828 eval {$watchers = $jira->GET ("/issue/$issue/watchers")};
832 # The watcher information returned by the above is incomplete. Let's complete
836 for (@{$watchers->{watchers}}) {
839 eval {$user = $jira->GET ("/user?username=$_->{key}")};
842 push @watchers, $user;
845 $main::log->err ("Unable to find user record for $_->{name}")
846 unless $_->{name} eq 'jira-admin';
854 sub updateIssueWatchers ($%) {
855 my ($issue, %watchers) = @_;
857 my $existingWatchers;
859 eval {$existingWatchers = $jira->GET ("/issue/$issue/watchers")};
861 return "Unable to get issue $issue\n$@" if $@;
863 for (@{$existingWatchers->{watchers}}) {
864 # Cleanup: Remove the current user from the watchers list.
865 # If he's on the list then remove him.
866 if ($_->{name} eq $jira->{username}) {
867 $jira->DELETE ("/issue/$issue/watchers?username=$_->{name}");
869 $main::total{"Admins destroyed"}++;
872 # Delete any matching watchers
873 delete $watchers{lc ($_->{name})} if $watchers{lc ($_->{name})};
876 return '' if keys %watchers == 0;
880 for (keys %watchers) {
881 if ($main::opts{exec}) {
882 eval {$jira->POST ("/issue/$issue/watchers", undef, $_)};
885 $main::log->warn ("Unable to add user $_ as a watcher to JIRA Issue $issue") if $main::log;
887 $main::total{'Watchers skipped'}++;
891 $main::total{'Watchers added'}++;
894 $main::log->msg ("Would have added user $_ as a watcher to JIRA Issue $issue") if $main::log;
896 $main::total{'Watchers that would have been added'}++;
900 $main::total{'Issues updated'}++ if $issueUpdated;
903 } # updateIssueWatchers
905 sub getUsersGroups ($) {
908 my ($result, %query);
911 username => $username,
915 eval {$result = $jira->GET ('/user/', \%query)};
919 for (@{$result->{groups}{items}}) {
920 push @groups, $_->{name};
926 sub updateUsersGroups ($@) {
927 my ($username, @groups) = @_;
929 my ($result, @errors);
931 my @oldgroups = getUsersGroups $username;
933 # We can't always add groups to the new user due to either the group not being
934 # in the new LDAP directory or we are unable to see it. If we attempt to JIRA
935 # will try to add the group and we don't have write permission to the
936 # directory. So we'll just return @errors and let the caller deal with it.
937 for my $group (@groups) {
938 next if grep {$_ eq $group} @oldgroups;
940 eval {$result = $jira->POST ('/group/user', {groupname => $group}, {name => $username})};
942 push @errors, $@ if $@;
946 } # updateUsersGroups
948 sub copyGroupMembership ($$) {
949 my ($from_username, $to_username) = @_;
951 return updateUsersGroups $to_username, getUsersGroups $from_username;
952 } # copyGroupMembership
954 sub updateColumn ($$$%) {
955 my ($table, $oldvalue, $newvalue, %info) = @_;
957 # UGH! Sometimes values need to be quoted
958 $oldvalue = quotemeta $oldvalue;
959 $newvalue = quotemeta $newvalue;
961 my $condition = "$info{column} = '$oldvalue'";
962 $condition .= " and $info{condition}" if $info{condition};
963 my $statement = "update $table set $info{column} = '$newvalue' where $condition";
965 my $nbrRows = count $table, $condition;
968 if ($main::opts{exec}) {
969 $main::total{'Rows updated'}++;
971 $jiradb->do ($statement);
973 _checkDBError 'Unable to execute statement', $statement;
975 $main::total{'Rows would be updated'}++;
977 $main::log->msg ("Would have executed $statement") if $main::log;
984 sub renameUsers (%) {
987 for my $olduser (sort keys %users) {
988 my $newuser = $users{$olduser};
990 $main::log->msg ("Renaming $olduser -> $newuser") if $main::log;
991 display ("Renaming $olduser -> $newuser");
993 if ($main::opts{exec}) {
994 $main::total{'Users renamed'}++;
996 $main::total{'Users would be updated'}++;
999 for my $table (sort keys %tables) {
1000 $main::log->msg ("\tTable: $table Column: ", 1) if $main::log;
1002 my @columns = @{$tables{$table}};
1004 for my $column (@columns) {
1005 my %info = %$column;
1007 $main::log->msg ("$info{column} ", 1) if $main::log;
1009 my $rowsUpdated = updateColumn ($table, $olduser, $newuser, %info);
1012 my $msg = " $rowsUpdated row";
1013 $msg .= 's' if $rowsUpdated > 1;
1014 $msg .= ' would have been' unless $main::opts{exec};
1017 $main::log->msg ($msg, 1) if $main::log;
1021 $main::log->msg ('') if $main::log;
1024 if (my @result = copyGroupMembership ($olduser, $newuser)) {
1025 # Skip errors of the form 'Could not add user... group is read-only
1026 @result = grep {!/Could not add user.*group is read-only/} @result;
1029 $main::log->err ("Unable to copy group membership from $olduser -> $newuser\n@result", 1) if @result;
1035 return $main::log ? $main::log->errors : 0;