Adding some files of recent work.
authorAndrew DeFaria <adefaria@audience.local>
Mon, 21 Dec 2015 18:11:32 +0000 (10:11 -0800)
committerAndrew DeFaria <adefaria@audience.local>
Mon, 21 Dec 2015 18:11:32 +0000 (10:11 -0800)
26 files changed:
Confluence/lib/Confluence.pm [new file with mode: 0644]
JIRA/importComments.pl [new file with mode: 0644]
JIRA/jiradep.pl [new file with mode: 0644]
JIRA/lib/BugzillaUtils.pm [new file with mode: 0644]
JIRA/lib/JIRAUtils.pm [new file with mode: 0644]
JIRA/updateWatchLists.pl [new file with mode: 0644]
Perforce/getPicture.conf [new file with mode: 0644]
Perforce/getPicture.pl [new file with mode: 0755]
Perforce/lib/Perforce.pm [new file with mode: 0644]
Perforce/renameUser.pl [new file with mode: 0755]
audience/JIRA/importComments.pl [deleted file]
audience/JIRA/jiradep.pl [deleted file]
audience/JIRA/lib/BugzillaUtils.pm [deleted file]
audience/JIRA/lib/JIRAUtils.pm [deleted file]
audience/JIRA/updateWatchLists.pl [deleted file]
etc/LDAP.conf [new file with mode: 0644]
rmc/Audience.png [new file with mode: 0644]
rmc/Makefile [new file with mode: 0644]
rmc/index.pl [new file with mode: 0644]
rmc/rmc.conf [new file with mode: 0644]
rmc/rmc.css [new file with mode: 0644]
rmc/rmc.js [new file with mode: 0644]
rmc/rmc.pl [new file with mode: 0644]
rmc/rmctable.js [new file with mode: 0644]
test/testConfluence.pl [new file with mode: 0755]
test/testldap.pl [new file with mode: 0755]

diff --git a/Confluence/lib/Confluence.pm b/Confluence/lib/Confluence.pm
new file mode 100644 (file)
index 0000000..2ad6d18
--- /dev/null
@@ -0,0 +1,118 @@
+=pod
+
+=head1 NAME $RCSfile: JIRAUtils.pm,v $
+
+Some shared functions dealing with JIRA
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: 1.0 $
+
+=item Created
+
+Fri Mar 12 10:17:44 PST 2004
+
+=item Modified
+
+$Date: 2013/05/30 15:48:06 $
+
+=back
+
+=head1 ROUTINES
+
+The following routines are exported:
+
+=cut
+
+package Confluence;
+
+use strict;
+use warnings;
+
+use File::Basename;
+use MIME::Base64;
+
+use Display;
+use GetConfig;
+use Carp;
+
+use REST::Client;
+
+our $VERSION  = '$Revision: 1.0 $';
+   ($VERSION) = ($VERSION =~ /\$Revision: (.*) /);
+   
+my $confluenceConf = $ENV{CONFLUENCE_CONF} || dirname (__FILE__) . '../etc/confluence.conf';
+
+my %OPTS = GetConfig $confluenceConf if -r $confluenceConf;   
+
+sub _get () {
+  my ($self, $url) = @_;
+  
+  unless ($self->{headers}) {
+    $self->{headers} = { 
+      Accept        => 'application/json',
+      Authorization => 'Basic ' 
+                     . encode_base64 ($self->{username} . ':' . $self->{password}),
+    };
+  } # unless
+  
+  return $self->{REST}->GET ($url, $self->{headers});
+} # _get
+
+sub new (;%) {
+  my ($class, %parms) = @_;
+  
+  my $self = bless {}, $class;
+  
+  $self->{username} = $parms{username} || $OPTS{username} || $ENV{CONFLUENCE_USERNAME};
+  $self->{password} = $parms{password} || $OPTS{password} || $ENV{CONFLUENCE_PASSWORD};
+  $self->{server}   = $parms{server}   || $OPTS{server}   || $ENV{CONFLUENCE_SERVER};
+  $self->{port}     = $parms{port}     || $OPTS{port}     || $ENV{CONFLUENCE_PORT};
+  $self->{URL}      = "http://$self->{server}:$self->{port}/rest/api";
+  
+  return $self->connect;
+} # new
+
+sub connect () {
+  my ($self) = @_;
+  
+  $self->{REST} = REST::Client->new (
+    host => "http://$self->{server}:$self->{port}",
+  );
+  
+  $self->{REST}->getUseragent()->ssl_opts (verify_hostname => 0);
+  $self->{REST}->setFollow (1);
+   
+  return $self; 
+} # connect
+
+sub getContent (%) {
+  my ($self, %parms) = @_;
+  
+  my $url  = 'content?';
+  
+  my @parms;
+  
+  push @parms, "type=$parms{type}"             if $parms{type};
+  push @parms, "spacekey=$parms{spaceKey}"     if $parms{spaceKey};
+  push @parms, "title=$parms{title}"           if $parms{title};
+  push @parms, "status=$parms{status}"         if $parms{status};
+  push @parms, "postingDay=$parms{postingDay}" if $parms{postingDay};
+  push @parms, "expand=$parms{expand}"         if $parms{expand};
+  push @parms, "start=$parms{start}"           if $parms{start};
+  push @parms, "limit==$parms{limit}"          if $parms{limit};
+  
+  my $content = $self->_get ('/content/', join ',', @parms);
+  
+  return $content;
+} # getContent
+
+1;
diff --git a/JIRA/importComments.pl b/JIRA/importComments.pl
new file mode 100644 (file)
index 0000000..6a1712c
--- /dev/null
@@ -0,0 +1,237 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+=pod
+
+=head1 NAME importComments.pl
+
+This will import the comments from Bugzilla and update the corresponding JIRA
+Issues.
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #1 $
+
+=item Created
+
+Thu Mar 20 10:11:53 PDT 2014
+
+=item Modified
+
+$Date: 2014/05/23 $
+
+=back
+
+=head1 SYNOPSIS
+
+  $ importComments.pl [-bugzillaserver <bugshost>] [-login <login email>]
+                      [-jiraserver <server>]
+                      [-username <username>] [-password <password>] 
+                      [-bugids bugid,bugid,... | -file <filename>] 
+                      [-[no]exec]
+                      [-verbose] [-help] [-usage]
+
+  Where:
+
+    -v|erbose:       Display progress output
+    -he|lp:          Display full help
+    -usa|ge:         Display usage
+    -[no]e|xec:      Whether or not to update JIRA. -noexec says only 
+                     tell me what you would have updated.
+    -use|rname:      Username to log into JIRA with (Default: jira-admin)
+    -p|assword:      Password to log into JIRA with (Default: jira-admin's 
+                     password)
+    -bugzillaserver: Machine where Bugzilla lives (Default: bugs-dev)
+    -jiraserver:     Machine where Jira lives (Default: jira-dev)
+    -bugi|ds:        Comma separated list of BugIDs to process
+    -f|ile:          File of BugIDs, one per line
+
+=head1 DESCRIPTION
+
+This will import the comments from Bugzilla and update the corresponding JIRA
+Issues.
+
+=cut
+
+use FindBin;
+use lib "$FindBin::Bin/lib";
+
+$| = 1;
+
+use DBI;
+use Display;
+use Logger;
+use TimeUtils;
+use Utils;
+use JIRAUtils;
+use BugzillaUtils;
+
+use Getopt::Long; 
+use Pod::Usage;
+
+our %opts = (
+  exec           => 0,
+  bugzillaserver => $ENV{BUGZILLASERVER} || 'bugs-dev',
+  jiraserver     => $ENV{JIRASERVER}     || 'jira-dev:8081',
+  username       => $ENV{USERNAME},
+  password       => $ENV{PASSWORD},
+  usage          => sub { pod2usage },
+  help           => sub { pod2usage (-verbose => 2)},
+  verbose        => sub { set_verbose },
+  quiet          => 0,
+);
+
+our ($log, %total);
+
+sub sanitize ($) {
+  my ($str) = @_;
+  
+  my $p4web    = 'http://p4web.audience.local:8080/@md=d&cd=//&c=vLW@/';
+  my $bugzilla = 'http://bugs.audience.com/show_bug.cgi?id=';
+
+  # 0x93 (147) and 0x94 (148) are "smart" quotes
+  $str =~ s/[\x93\x94]/"/gm;
+  # 0x91 (145) and 0x92 (146) are "smart" singlequotes
+  $str =~ s/[\x91\x92]/'/gm;
+  # 0x96 (150) and 0x97 (151) are emdashes
+  $str =~ s/[\x96\x97]/--/gm;
+  # 0x85 (133) is an ellipsis
+  $str =~ s/\x85/.../gm;
+  # 0x95 &bull; replacement for unordered list
+  $str =~ s/\x95/*/gm;
+
+  # Make P4Web links for "CL (\d{3,6}+)"
+  $str =~ s/CL\s*(\d{3,6}+)/CL \[$1|${p4web}$1\?ac=10\]/igm;
+
+  # Make Bugzilla links for "Bug ID (\d{1,5}+)"
+  $str =~ s/Bug\s*ID\s*(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
+
+  # Make Bugzilla links for "Bug # (\d{1,5}+)"
+  $str =~ s/Bug\s*#\s*(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
+
+  # Make Bugzilla links for "Bug (\d{1,5}+)"
+  $str =~ s/Bug\s*(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
+
+  # Convert bug URLs to be more proper
+  $str =~ s/https\:\/\/bugs\.audience\.com\/show_bug\.cgi\?id=(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
+
+  return $str;
+} # sanitize
+
+sub addComments ($$) {
+  my ($jiraIssue, $bugid) = @_;
+  
+  my @comments = @{getBugComments ($bugid)};
+  
+  # Note: In Bugzilla the first comment is considered the description.
+  my $description = shift @comments;
+  
+  my $result = addDescription $jiraIssue, sanitize $description;
+  
+  $total{'Descriptions added'}++;
+  
+  return $result if $result =~ /^Unable to add comment/;
+   
+  # Process the remaining comments  
+  for (@comments) {
+    $result = addJIRAComment $jiraIssue, sanitize $_;
+    
+    if ($result =~ /Comment added/) {
+      $total{'Comments imported'}++;
+    } else {
+      return $result;
+    } # if
+  } # for
+  
+  $result = '' unless $result;
+  
+  return $result;
+} # addComments
+
+sub main () {
+  my $startTime = time;
+  
+  GetOptions (
+    \%opts,
+    'verbose',
+    'usage',
+    'help',
+    'exec!',
+    'quiet',
+    'username=s',
+    'password=s',
+    'bugids=s@',
+    'file=s',
+    'jiraserver=s',
+    'bugzillaserver=s',
+    'linkbugzilla',
+    'relinkbugzilla'
+  ) or pod2usage;
+  
+  $log = Logger->new;
+
+  if ($opts{file}) {
+    open my $file, '<', $opts{file} 
+      or $log->err ("Unable to open $opts{file} - $!", 1);
+      
+    $opts{bugids} = [<$file>];
+    
+    chomp @{$opts{bugids}};
+  } else {
+    my @bugids;
+    
+    push @bugids, (split /,/, join (',', $_)) for (@{$opts{bugids}}); 
+  
+    $opts{bugids} = [@bugids];
+  } # if
+  
+  pod2usage 'Must specify -bugids <bugid>[,<bugid>,...] or -file <filename>'
+    unless $opts{bugids};
+  
+  openBugzilla $opts{bugzillaserver}
+    or $log->err ("Unable to connect to $opts{bugzillaserver}", 1);
+  
+  Connect2JIRA ($opts{username}, $opts{password}, $opts{jiraserver})
+    or $log->err ("Unable to connect to $opts{jiraserver}", 1);
+
+  $log->msg ("Processing comments");
+
+  for (@{$opts{bugids}}) {
+    my $jiraIssue = findIssue $_;
+    
+    if ($jiraIssue =~ /^[A-Z]{1,5}-\d+$/) {
+      my $result = addComments $jiraIssue, $_;
+      
+      if ($result =~ /^Unable/) {
+        $total{'Comment failures'}++;
+
+        $log->err ("Unable to add comments for $jiraIssue ($_)\n$result");
+      } elsif ($result =~ /^Comment added/) {
+        $log->msg ("Added comments for $jiraIssue ($_)");
+      } elsif ($result =~ /^Would have linked/) {
+        $total{'Comments would be added'}++;
+      } # if
+    } else {
+      $total{'Missing JIRA Issues'}++;
+      
+      $log->err ("Unable to find JIRA Issue for Bug $_");
+    } # if
+  } # for
+
+  display_duration $startTime, $log;
+  
+  Stats (\%total, $log) unless $opts{quiet};
+
+  return 0;
+} # main
+
+exit main;
diff --git a/JIRA/jiradep.pl b/JIRA/jiradep.pl
new file mode 100644 (file)
index 0000000..a8ce4c4
--- /dev/null
@@ -0,0 +1,372 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+=pod
+
+=head1 NAME jiradep.pl
+
+Update Bugzilla dependencies (Dependencies/Blockers/Duplicates and Related),
+transfering those relationships over to any matching JIRA issues. 
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #1 $
+
+=item Created
+
+Thu Mar 20 10:11:53 PDT 2014
+
+=item Modified
+
+$Date: 2014/05/23 $
+
+=back
+
+=head1 SYNOPSIS
+
+  $ jiradep.pl [-bugzillaserver <bugshost>] [-login <login email>]
+               [-jiraserver <server>]
+               [-username <username>] [-password <password>] 
+               [-bugids bugid,bugid,... | -file <filename>] 
+               [-[no]exec] [-linkbugzilla] [-relinkbugzilla]
+               [-verbose] [-help] [-usage]
+
+  Where:
+
+    -v|erbose:       Display progress output
+    -he|lp:          Display full help
+    -usa|ge:         Display usage
+    -[no]e|xec:      Whether or not to update Bugilla. -noexec says only 
+                     tell me what you would have updated.
+    -use|rname:      Username to log into JIRA with (Default: jira-admin)
+    -p|assword:      Password to log into JIRA with (Default: jira-admin's 
+                     password)
+    -bugzillaserver: Machine where Bugzilla lives (Default: bugs-dev)
+    -jiraserver:     Machine where Jira lives (Default: jira-dev)
+    -bugi|ds:        Comma separated list of BugIDs to process
+    -f|ile:          File of BugIDs, one per line
+    -linkbugzilla:   If specified and we find that we cannot translate
+                     a Bugzilla Bud ID to a JIRA Issue then create a 
+                     remote link for the Bugzilla Bug. (Default:
+                     do not create Bugzilla remote links).
+    -relinkbugzilla: Scan current Remote Bugzilla links and if there
+                     exists a corresponding JIRA issue, remove the 
+                     Remote Bugzilla link and make it a JIRA Issue
+                     link.
+    -jiradbhost:     Host name of the machine where the MySQL jiradb
+                     database is located (Default: cm-db-ldev01)
+
+=head1 DESCRIPTION
+
+This script will process all BugIDs translating them into JIRA Issues, if
+applicable. It will then determine the relationships of this BugID in Bugzilla -
+what it blocks, what it depends on, if it's a duplicate of another bug or if
+it has any related links. Those too will be translated to JIRA issues, again,
+if applicable. Then the JIRA issue will be updates to reflect these 
+relationships. 
+
+Note that it's not known at this time what to do for situations where BugIDs
+cannot be translated into JIRA issues if such Bugzilla bugs have not yet been
+migrated to JIRA. There's a though to simply make a Bugzilla Link but we will
+need to keep that in mind and when we import the next project to JIRA these
+old, no longer used Bugzilla Links should be converted to their corresponding 
+JIRA issue. Perhaps this script can do that too.
+
+=cut
+
+use FindBin;
+use lib "$FindBin::Bin/lib";
+
+$| = 1;
+
+use DBI;
+use Display;
+use Logger;
+use TimeUtils;
+use Utils;
+use JIRAUtils;
+use BugzillaUtils;
+
+use Getopt::Long; 
+use Pod::Usage;
+
+our %opts = (
+  exec           => 0,
+  bugzillaserver => $ENV{BUGZILLASERVER} || 'bugs-dev',
+  jiraserver     => $ENV{JIRASERVER}     || 'jira-dev',
+  jiradbhost     => $ENV{JIRA_DB_HOST}   || 'cm-db-ldev01',
+  username       => 'jira-admin',
+  password       => 'jira-admin',
+  usage          => sub { pod2usage },
+  help           => sub { pod2usage (-verbose => 2)},
+  verbose        => sub { set_verbose },
+  quiet          => 0,
+  usage          => sub { pod2usage },
+  help           => sub { pod2usage (-verbose => 2)},
+);
+
+our ($log, %total);
+
+my %relationshipMap = (
+  Blocks    => 'Dependencies Linked',
+  Duplicate => 'Duplicates Linked',
+  Related   => 'Related Linked',
+);
+
+sub callLink ($$$$) {
+  my ($from, $type, $to, $counter) = @_;
+
+  my $bugzillaType;
+  
+  if ($from =~ /^\d+/) {
+    if ($type eq 'Blocks') {
+      $bugzillaType = 'is blocked by (Bugzilla)';
+    } elsif ($type eq 'Duplicate') {
+      $bugzillaType = 'duplicate (Bugzilla)';
+    } elsif ($type eq 'Related') {
+      $bugzillaType = 'related (Bugzilla)';
+    } # if
+  } elsif ($to =~ /^\d+/) {
+    if ($type eq 'Blocks') {
+      $bugzillaType = 'blocks (Bugzilla)';
+    } elsif ($type eq 'Duplicate') {
+      $bugzillaType = 'duplicate (Bugzilla)';
+    } elsif ($type eq 'Related') {
+      $bugzillaType = 'related (Bugzilla)';
+    } # if
+  } # if
+    
+  $total{$counter}++;
+
+  if ($from =~ /^\d+/ && $to =~ /^\d+/) {
+    $total{'Skipped Bugzilla Links'}++;
+    
+    return "Refusing to link because both from ($from) and to ($to) links are still a Bugzilla link";
+  } elsif ($from =~ /^\d+/) {
+    if ($opts{linkbugzilla}) {
+      my $result = addRemoteLink $from, $bugzillaType, $to;
+
+      $total{'Bugzilla Links'}++ unless $result;
+    
+      if ($result eq '') {
+        return "Created remote $type link between Issue $to and Bug $from";
+      } else {
+        return $result;
+      } # if 
+    } else {
+      $total{'Skipped Bugzilla Links'}++;
+    
+      return "Refusing to link because from link ($from) is still a Bugzilla link";
+    } # if
+  } elsif ($to =~ /^\d+/) {
+    if ($opts{linkbugzilla}) {
+      my $result = addRemoteLink $to, $bugzillaType, $from;
+    
+      $total{'Bugzilla Links'}++ unless $result;
+
+      if (!defined $result) {
+        print "huh?";
+      }
+      if ($result eq '') {
+        return "Created remote $type link between Issue $from and Bug $to";
+      } else {
+        return $result;
+      } # if 
+    } else {
+      $total{'Skipped Bugzilla Links'}++;
+    
+      return "Refusing to link because to link ($to) is still a Bugzilla link";
+    } # if
+  } # if
+   
+  my $result = linkIssues $from, $type, $to;
+  
+  $log->msg ($result);
+    
+  if ($result =~ /^Unable/) {
+    $total{'Link Failures'}++;
+  } elsif ($result =~ /^Link made/) {
+    $total{'Links made'}++;
+  } elsif ($result =~ /^Would have linked/) {
+    $total{'Links would be made'}++;
+  } # if
+      
+  return;
+} # callLink
+
+sub relinkBugzilla (@) {
+  my (@bugids) = @_;
+  
+  my %mapRelationships = (
+    'blocks (Bugzilla)'           => 'Blocks',
+    'is blocked by (Bugzilla)'    => 'Blocks',
+    'duplicates (Bugzilla)'       => 'Duplicates',
+    'is duplicated by (Bugzilla)' => 'Duplicates',
+    # old versions...
+    'Bugzilla blocks'             => 'Blocks',
+    'Bugzilla is blocked by'      => 'Blocks',
+    'Bugzilla duplicates'         => 'Duplicates',
+    'Bugzilla is duplicated by'   => 'Duplicates',
+  );
+  
+  @bugids = getRemoteLinks unless @bugids;
+  
+  for my $bugid (@bugids) {
+    $total{'Remote Links Scanned'}++;
+    
+    my $links = findRemoteLinkByBugID $bugid;
+
+    my $jirafrom = findIssue ($bugid);
+    
+    next if $jirafrom !~ /^[A-Z]{1,5}-\d+$/;
+        
+    for (@$links) {
+      my %link = %$_;
+      
+      # Found a link to JIRA. Remove remotelink and make an issuelink
+      if ($mapRelationships{$link{relationship}}) {
+        my ($fromIssue, $toIssue);
+        
+        if ($link{relationship} =~ / by/) {
+          $fromIssue = $jirafrom;
+          $toIssue   = $link{issue};
+        } else {
+          $fromIssue = $link{issue};
+          $toIssue   = $jirafrom;
+        } # if
+        
+        my $status = promoteBug2JIRAIssue $bugid, $fromIssue, $toIssue,
+                                          $mapRelationships{$link{relationship}};
+
+        $log->err ($status) if $status =~ /Unable to link/;
+      } else {
+        $log->err ("Unable to handle relationships of type $link{relationship}");
+      } # if
+    } # for
+  } # for
+    
+  return;
+} # relinkBugzilla
+
+sub main () {
+  my $startTime = time;
+  
+  GetOptions (
+    \%opts,
+    'verbose',
+    'usage',
+    'help',
+    'exec!',
+    'quiet',
+    'username=s',
+    'password=s',
+    'bugids=s@',
+    'file=s',
+    'jiraserver=s',
+    'bugzillaserver=s',
+    'linkbugzilla',
+    'relinkbugzilla',
+    'jiradbhost=s',
+  ) or pod2usage;
+  
+  $log = Logger->new;
+
+  if ($opts{file}) {
+    open my $file, '<', $opts{file} 
+      or $log->err ("Unable to open $opts{file} - $!", 1);
+      
+    $opts{bugids} = [<$file>];
+    
+    chomp @{$opts{bugids}};
+  } else {
+    my @bugids;
+    
+    push @bugids, (split /,/, join (',', $_)) for (@{$opts{bugids}}); 
+  
+    $opts{bugids} = [@bugids];
+  } # if
+  
+  pod2usage 'Must specify -bugids <bugid>[,<bugid>,...] or -file <filename>'
+    unless ($opts{bugids} > 0 or $opts{relinkbugzilla});
+  
+  openBugzilla $opts{bugzillaserver}
+    or $log->err ("Unable to connect to $opts{bugzillaserver}", 1);
+  
+  Connect2JIRA ($opts{username}, $opts{password}, $opts{jiraserver})
+    or $log->err ("Unable to connect to $opts{jiraserver}", 1);
+
+  if ($opts{relinkbugzilla}) {
+    unless (@{$opts{bugids}}) {
+      relinkBugzilla;
+    } else {
+      relinkBugzilla $_ for @{$opts{bugids}}
+    } # unless
+        
+    Stats (\%total, $log);
+    
+    exit $log->errors;
+  } # if
+  
+  my %relationships;
+  
+  # The 'Blocks' IssueLinkType has two types of relationships in it - both
+  # blocks and dependson. Since JIRA has only one type - Blocks - we take
+  # the $dependson and flip the from and to.
+  my $blocks    = getBlockers @{$opts{bugids}};
+  my $dependson = getDependencies @{$opts{bugids}};
+  
+  # Now merge them - we did it backwards!
+  for my $fromLink (keys %$dependson) {
+    for my $toLink (@{$dependson->{$fromLink}}) {
+      push @{$relationships{Blocks}{$toLink}}, $fromLink;
+    } # for
+  } # for
+
+  #%{$relationships{Blocks}} = %$dependson;
+  
+  for my $fromLink (keys %$blocks) {
+    # Check to see if we already have the reverse of this link
+    for my $toLink (@{$blocks->{$fromLink}}) {
+      unless (grep {$toLink eq $_} keys %{$relationships{Blocks}}) {
+        push @{$relationships{Blocks}{$fromLink}}, $toLink;
+      } # unless
+    } # for
+  } # for
+  
+  $relationships{Duplicate} = getDuplicates   @{$opts{bugids}};
+  $relationships{Relates}   = getRelated      @{$opts{bugids}};
+  
+  # Process relationships (social programming... ;-)
+  $log->msg ("Processing relationships");
+  
+  for my $type (keys %relationshipMap) {
+    for my $from (keys %{$relationships{$type}}) {
+      for my $to (@{$relationships{$type}{$from}}) {
+        $total{'Relationships processed'}++;
+        
+        my $result = callLink $from, $type, $to, $relationshipMap{$type};
+        
+        $log->msg ($result) if $result;
+      } # for
+    } # for
+  } # if
+
+  display_duration $startTime, $log;
+  
+  Stats (\%total, $log) unless $opts{quiet};
+
+  return;
+} # main
+
+main;
+
+exit;
\ No newline at end of file
diff --git a/JIRA/lib/BugzillaUtils.pm b/JIRA/lib/BugzillaUtils.pm
new file mode 100644 (file)
index 0000000..899f506
--- /dev/null
@@ -0,0 +1,322 @@
+=pod
+
+=head1 NAME $RCSfile: BugzillaUtils.pm,v $
+
+Some shared functions dealing with Bugzilla. Note this uses DBI to directly
+access Bugzilla's database. This requires that your userid was granted access.
+For this I setup adefaria with pretty much read only access.
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: 1.0 $
+
+=item Created
+
+Fri Mar 12 10:17:44 PST 2004
+
+=item Modified
+
+$Date: 2013/05/30 15:48:06 $
+
+=head1 ROUTINES
+
+The following routines are exported:
+
+=cut
+
+package BugzillaUtils;
+
+use strict;
+use warnings;
+
+use base 'Exporter';
+
+use FindBin;
+use Display;
+use Carp;
+use DBI;
+
+use lib 'lib';
+
+use JIRAUtils;
+
+our $bugzilla;
+
+our @EXPORT = qw (
+  openBugzilla
+  getRelationships
+  getDependencies
+  getBlockers
+  getDuplicates
+  getRelated
+  getBug
+  getBugComments
+  getWatchers
+);
+
+sub _checkDBError ($$) {
+  my ($msg, $statement) = @_;
+
+  my $dberr    = $bugzilla->err;
+  my $dberrmsg = $bugzilla->errstr;
+
+  $dberr    ||= 0;
+  $dberrmsg ||= 'Success';
+
+  my $message = '';
+
+  if ($dberr) {
+    my $function = (caller (1)) [3];
+
+    $message = "$function: $msg\nError #$dberr: $dberrmsg\n"
+             . "SQL Statement: $statement";
+  } # if
+
+  $main::log->err ($message, $dberr) if $dberr;
+
+  return;
+} # _checkDBError
+
+sub openBugzilla (;$$$$) {
+  my ($dbhost, $dbname, $dbuser, $dbpass) = @_;
+
+  $dbhost //= 'jira-dev';
+  $dbname //= 'bugzilla';
+  $dbuser //= 'adefaria';
+  $dbpass //= 'reader';
+  
+  $main::log->msg ("Connecting to Bugzilla ($dbuser\@$dbhost)");
+  
+  $bugzilla = DBI->connect (
+    "DBI:mysql:$dbname:$dbhost",
+    $dbuser,
+    $dbpass, {
+      PrintError => 0,
+      RaiseError => 1,
+    },
+  );
+  
+  _checkDBError 'Unable to execute statement', 'Connect';
+
+  return $bugzilla;
+} # openBugzilla
+
+sub getBug ($;@) {
+  my ($bugid, @fields) = @_;
+  
+  push @fields, 'short_desc' unless @fields;
+  
+  my $statement = 'select ' . join (',', @fields) .
+                  " from bugs where bug_id = $bugid";
+                  
+  my $sth = $bugzilla->prepare ($statement);
+
+  _checkDBError 'Unable to prepare statement', $statement;
+  
+  _checkDBError 'Unable to execute statement', $statement;
+
+  $sth->execute;
+
+  return $sth->fetchrow_hashref;
+} # getBug
+
+sub getBugComments ($) {
+  my ($bugid) = @_;
+  
+  my $statement = <<"END";
+select 
+  bug_id, 
+  bug_when, 
+  substring_index(login_name,'\@',1) as username,
+  thetext
+from
+  longdescs,
+  profiles
+where
+  who    = userid and
+  bug_id = $bugid 
+END
+  
+  my $sth = $bugzilla->prepare ($statement);
+  
+  _checkDBError 'Unable to prepare statement', $statement;
+
+  $sth->execute;
+  
+  _checkDBError 'Unable to execute statement', $statement;
+  
+  my @comments;
+  
+  while (my $comment = $sth->fetchrow_hashref) {
+    my $commentText = <<"END";
+The following comment was entered by [~$comment->{username}] on $comment->{bug_when}:
+
+$comment->{thetext}
+END
+
+    push @comments, $commentText;
+  } # while
+  
+  return \@comments;
+} # getBugComments
+
+sub getRelationships ($$$$@) {
+  my ($table, $returnField, $testField, $relationshipType, @bugs) = @_;
+  
+  $main::log->msg ("Getting $relationshipType");
+  
+  my $statement = "select $returnField from $table where $table.$testField = ?";
+
+  my $sth = $bugzilla->prepare ($statement);
+
+  _checkDBError 'Unable to prepare statement', $statement;
+  
+  my %relationships;
+
+  my %bugmap;
+  
+  map {$bugmap{$_} = 1} @bugs unless %bugmap;
+      
+  for my $bugid (@bugs) {
+    $sth->execute ($bugid);
+    
+    _checkDBError 'Unable to exit statement', $statement;
+    
+    my $result = JIRAUtils::findIssue ($bugid, %bugmap);
+    
+    if ($result =~ /^Unable/) {
+      $main::log->warn ($result);
+      
+      $main::total{'Missing JIRA Issues'}++;
+      
+      undef $result;
+    } elsif ($result =~ /^Future/) {
+      $main::total{'Future JIRA Issues'}++;
+      
+      undef $result;
+    } # if
+    
+    my $jiraIssue = $result;
+    my $key       = $jiraIssue || $bugid;
+
+    my @relationships;
+    my $relations = $sth->fetchall_arrayref;
+    my @relations;
+    
+    map {push @relations, $_->[0]} @$relations;
+    
+    for my $relation (@relations) {
+      $jiraIssue = JIRAUtils::findIssue ($relation);
+      
+      if ($jiraIssue =~ /^Unable/ || $jiraIssue =~ /^Future/) {
+        $main::log->warn ($jiraIssue);
+
+        $main::total{'Missing JIRA Issues'}++ if $jiraIssue =~ /^Unable/;
+        $main::total{'Future JIRA Issues'}++  if $jiraIssue =~ /^Future/;
+        
+        push @relationships, $relation;
+      } else {
+        push @relationships, $jiraIssue;
+      } # if
+    } # for
+      
+    push @{$relationships{$key}}, @relationships if @relationships;
+  } # for
+  
+  $main::total{$relationshipType} = keys %relationships;
+  
+  return \%relationships;
+} # getRelationships
+
+sub getDependencies (@) {
+  my (@bugs) = @_;
+
+  return getRelationships (
+    'dependencies', # table 
+    'dependson',    # returned field
+    'blocked',      # test field
+    'Depends on',   # relationship
+    @bugs
+  );
+} # getDependencies
+
+sub getBlockers (@) {
+  my (@bugs) = @_;
+  
+  return getRelationships (
+    'dependencies', 
+    'blocked', 
+    'dependson', 
+    'Blocks',
+    @bugs
+  );
+} # getBlockers
+
+sub getDuplicates (@) {
+  my (@bugs) = @_;
+  
+  return getRelationships (
+    'duplicates', 
+    'dupe', 
+    'dupe_of', 
+    'Duplicates',
+    @bugs
+  );
+} # getDuplicates
+
+sub getRelated (@) {
+  my (@bugs) = @_;
+  
+  return getRelationships (
+    'bug_see_also', 
+    'value', 
+    'bug_id', 
+    'Relates',
+    @bugs
+  );
+} # getRelated
+
+sub getWatchers ($) {
+  my ($bugid) = @_;
+  
+  my $statement = <<"END";
+select 
+  profiles.login_name
+from 
+  cc,
+  profiles
+where 
+  cc.who = profiles.userid and
+  bug_id = ?
+END
+
+  my $sth = $bugzilla->prepare ($statement);
+
+  _checkDBError 'Unable to prepare statement', $statement;
+
+  $sth->execute ($bugid);
+
+  _checkDBError 'Unable to execute statement', $statement;
+
+  my @rows = @{$sth->fetchall_arrayref};
+
+  my %watchers;
+
+  for (@rows) {
+    if ($$_[0] =~ /(.*)\@/) {
+      $watchers{$1} = 1;
+    } # if
+
+    $main::total{'Watchers Processed'}++;  
+  } # for
+
+  return %watchers;
+} # getWatchers
\ No newline at end of file
diff --git a/JIRA/lib/JIRAUtils.pm b/JIRA/lib/JIRAUtils.pm
new file mode 100644 (file)
index 0000000..b57219d
--- /dev/null
@@ -0,0 +1,1038 @@
+=pod
+
+=head1 NAME $RCSfile: JIRAUtils.pm,v $
+
+Some shared functions dealing with JIRA
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: 1.0 $
+
+=item Created
+
+Fri Mar 12 10:17:44 PST 2004
+
+=item Modified
+
+$Date: 2013/05/30 15:48:06 $
+
+=back
+
+=head1 ROUTINES
+
+The following routines are exported:
+
+=cut
+
+package JIRAUtils;
+
+use strict;
+use warnings;
+
+use base 'Exporter';
+
+use FindBin;
+use Display;
+use Carp;
+use DBI;
+
+use JIRA::REST;
+use BugzillaUtils;
+
+our $jira;
+
+our @EXPORT = qw (
+  Connect2JIRA
+  addDescription
+  addJIRAComment
+  addRemoteLink
+  attachFiles2Issue
+  attachmentExists
+  blankBugzillaNbr
+  copyGroupMembership
+  count
+  findIssue
+  findIssues
+  findRemoteLinkByBugID
+  getIssue
+  getIssueFromBugID
+  getIssueLinkTypes
+  getIssueLinks
+  getIssueWatchers
+  getIssues
+  getNextIssue
+  getRemoteLink
+  getRemoteLinkByBugID
+  getRemoteLinks
+  getUsersGroups
+  linkIssues
+  promoteBug2JIRAIssue
+  removeRemoteLink
+  renameUsers
+  updateColumn
+  updateIssueWatchers
+  updateUsersGroups
+);
+
+my (@issueLinkTypes, %cache, $jiradb, %findQuery);
+
+my %tables = (
+  ao_08d66b_filter_display_conf => [
+                                     {column    => 'user_name'}
+                                   ],
+  ao_0c0737_vote_info           => [
+                                     {column    => 'user_name'}
+                                   ],
+  ao_3a112f_audit_log_entry     => [
+                                     {column    => 'user'}
+                                   ],                                   
+  ao_563aee_activity_entity     => [
+                                     {column    => 'username'}
+                                   ],
+  ao_60db71_auditentry          => [
+                                     {column    => 'user'}
+                                   ],
+  ao_60db71_boardadmins         => [
+                                     {column    => "'key'"}
+                                   ],
+  ao_60db71_rapidview           => [
+                                     {column    => 'owner_user_name'}
+                                   ],
+  ao_caff30_favourite_issue     => [
+                                     {column    => 'user_key'}
+                                   ],
+#  app_user                      => [
+#                                     {column    => 'user_key'},
+#                                     {column    => 'lower_user_name'},
+#                                   ],
+  audit_log                     => [
+                                     {column    => 'author_key'}
+                                   ],
+  avatar                        => [
+                                     {column    => 'owner'}
+                                   ],
+  changegroup                   => [
+                                     {column    => 'author'}
+                                   ],
+  changeitem                    => [
+                                     {column    => 'oldvalue',
+                                      condition => 'field = "assignee"'},
+                                     {column    => 'newvalue',
+                                      condition => 'field = "assignee"'},
+                                   ],
+  columnlayout                  => [
+                                     {column    => 'username'},
+                                   ],
+  component                     => [
+                                     {column    => 'lead'},
+                                   ],
+  customfieldvalue              => [
+                                     {column    => 'stringvalue'},
+                                   ],
+  favouriteassociations         => [
+                                     {column    => 'username'},
+                                   ],                                   
+#  cwd_membership                => [
+#                                     {column    => 'child_name'},
+#                                     {column    => 'lower_child_name'},
+#                                   ],
+  fileattachment                => [
+                                     {column    => 'author'},
+                                   ],
+  filtersubscription            => [
+                                     {column    => 'username'},
+                                   ],
+  jiraaction                    => [
+                                     {column    => 'author'},
+                                   ],
+  jiraissue                     => [
+                                     {column    => 'reporter'},
+                                     {column    => 'assignee'},
+                                   ],
+  jiraworkflows                 => [
+                                     {column    => 'creatorname'},
+                                   ],
+  membershipbase                => [
+                                     {column    => 'user_name'},
+                                   ],
+  os_currentstep                => [
+                                     {column    => 'owner'},
+                                     {column    => 'caller'},
+                                   ],
+  os_historystep                => [
+                                     {column    => 'owner'},
+                                     {column    => 'caller'},
+                                   ],
+  project                       => [
+                                     {column    => 'lead'},
+                                   ],
+  portalpage                    => [
+                                     {column    => 'username'},
+                                   ],
+  schemepermissions             => [
+                                     {column    => 'perm_parameter',
+                                      condition => 'perm_type = "user"'},
+                                   ],
+  searchrequest                 => [
+                                     {column    => 'authorname'},
+                                     {column    => 'username'},
+                                   ],
+  userassociation               => [
+                                     {column    => 'source_name'},
+                                   ],
+  userbase                      => [
+                                     {column    => 'username'},
+                                   ],
+  userhistoryitem               => [
+                                     {column    => 'username'}
+                                   ],
+  worklog                       => [
+                                     {column    => 'author'},
+                                   ],
+);
+
+sub _checkDBError ($;$) {
+  my ($msg, $statement) = @_;
+
+  $statement //= 'Unknown';
+  
+  if ($main::log) {
+   $main::log->err ('JIRA database not opened!', 1) unless $jiradb;
+  } # if
+  
+  my $dberr    = $jiradb->err;
+  my $dberrmsg = $jiradb->errstr;
+  
+  $dberr    ||= 0;
+  $dberrmsg ||= 'Success';
+
+  my $message = '';
+  
+  if ($dberr) {
+    my $function = (caller (1)) [3];
+
+    $message = "$function: $msg\nError #$dberr: $dberrmsg\n"
+             . "SQL Statement: $statement";
+  } # if
+
+  if ($main::log) {
+    $main::log->err ($message, 1) if $dberr;
+  } # if
+
+  return;
+} # _checkDBError
+
+sub openJIRADB (;$$$$) {
+  my ($dbhost, $dbname, $dbuser, $dbpass) = @_;
+
+  $dbhost //= $main::opts{jiradbhost};
+  $dbname //= 'jiradb';
+  $dbuser //= 'root';
+  $dbpass //= 'r00t';
+  
+  $main::log->msg ("Connecting to JIRA ($dbuser\@$dbhost)...") if $main::log;
+  
+  $jiradb = DBI->connect (
+    "DBI:mysql:$dbname:$dbhost",
+    $dbuser,
+    $dbpass, {
+      PrintError => 0,
+      RaiseError => 1,
+    },
+  );
+
+  _checkDBError "Unable to open $dbname ($dbuser\@$dbhost)";
+  
+  return $jiradb;
+} # openJIRADB
+
+sub Connect2JIRA (;$$$) {
+  my ($username, $password, $server) = @_;
+
+  my %opts;
+  
+  $opts{username} = $username || 'jira-admin';
+  $opts{password} = $password || $ENV{PASSWORD}    || 'jira-admin';
+  $opts{server}   = $server   || $ENV{JIRA_SERVER} || 'jira-dev';
+  $opts{URL}      = "http://$opts{server}/rest/api/latest";
+  
+  $main::log->msg ("Connecting to JIRA ($opts{username}\@$opts{server})") if $main::log;
+  
+  $jira = JIRA::REST->new ($opts{URL}, $opts{username}, $opts{password});
+
+  # Store username as we might need it (see updateIssueWatchers)
+  $jira->{username} = $opts{username};
+  
+  return $jira;  
+} # Connect2JIRA
+
+sub count ($$) {
+  my ($table, $condition) = @_;
+
+  my $statement;
+  
+  $jiradb = openJIRADB unless $jiradb;
+
+  if ($condition) {
+    $statement = "select count(*) from $table where $condition";
+  } else {
+    $statement = "select count(*) from $table";
+  } # if
+
+  my $sth = $jiradb->prepare ($statement);
+  
+  _checkDBError 'count: Unable to prepare statement', $statement;
+
+  $sth->execute;
+  
+  _checkDBError 'count: Unable to execute statement', $statement;
+
+  # Get return value, which should be how many message there are
+  my @row = $sth->fetchrow_array;
+
+  # Done with $sth
+  $sth->finish;
+
+  my $count;
+
+  # Retrieve returned value
+  unless ($row[0]) {
+    $count = 0
+  } else {
+    $count = $row[0];
+  } # unless
+
+  return $count
+} # count
+
+sub addDescription ($$) {
+  my ($issue, $description) = @_;
+  
+  if ($main::opts{exec}) {
+    eval {$jira->PUT ("/issue/$issue", undef, {fields => {description => $description}})};
+  
+    if ($@) {
+      return "Unable to add description\n$@";
+    } else {
+      return 'Description added';
+    } # if
+  } # if
+} # addDescription
+
+sub addJIRAComment ($$) {
+  my ($issue, $comment) = @_;
+  
+  if ($main::opts{exec}) {
+    eval {$jira->POST ("/issue/$issue/comment", undef, { body => $comment })};
+  
+    if ($@) {
+      return "Unable to add comment\n$@";
+    } else {
+      return 'Comment added';
+    } # if
+  } else {
+    return "Would have added comments to $issue";
+  } # if
+} # addJIRAComment
+
+sub blankBugzillaNbr ($) {
+  my ($issue) = @_;
+  
+  eval {$jira->PUT ("/issue/$issue", undef, {fields => {'Bugzilla Bug Origin' => ''}})};
+  #eval {$jira->PUT ("/issue/$issue", undef, {fields => {'customfield_10132' => ''}})};
+  
+  if ($@) {
+    return "Unable to blank Bugzilla number$@\n"
+  } else {
+    return 'Corrected'
+  } # if
+} # blankBugzillaNbr
+
+sub attachmentExists ($$) {
+  my ($issue, $filename) = @_;
+  
+  my $attachments = getIssue ($issue, qw(attachment));
+  
+  for (@{$attachments->{fields}{attachment}}) {
+    return 1 if $filename eq $_->{filename};
+  } # for
+  
+  return 0;
+} # attachmentExists
+
+sub attachFiles2Issue ($@) {
+  my ($issue, @files) = @_;
+
+  my $status = $jira->attach_files ($issue, @files);
+  
+  return $status;
+} # attachFiles2Issue
+
+sub getIssueFromBugID ($) {
+  my ($bugid) = @_;
+  
+  my $issue;
+  
+  my %query = (
+    jql    => "\"Bugzilla Bug Origin\" ~ $bugid",
+    fields => [ 'key' ],
+  );
+  
+  eval {$issue = $jira->GET ("/search/", \%query)};
+
+  my $issueID = $issue->{issues}[0]{key};
+  
+  return $issue->{issues} if @{$issue->{issues}} > 1;
+  return $issueID;
+} # getIssueFromBugID
+
+sub findIssue ($%) {
+  my ($bugid, %bugmap) = @_;
+  
+=pod
+  # Check the cache...
+  if ($cache{$bugid}) {
+    if ($cache{$bugid} =~ /^\d+/) {
+      # We have a cache hit but the contents here are a bugid. This means we had
+      # searched for the corresponding JIRA issue for this bug before and came
+      # up empty handed. In this situtaion we really have:
+      return "Unable to find a JIRA issue for Bug $bugid"; 
+    } else {
+      return $cache{$bugid};
+    } # if
+  } # if
+=cut  
+  my $issue;
+
+  my %query = (
+    jql    => "\"Bugzilla Bug Origin\" ~ $bugid",
+    fields => [ 'key' ],
+  );
+  
+  eval {$issue = $jira->GET ("/search/", \%query)};
+
+  my $issueID = $issue->{issues}[0]{key};
+  
+  if (@{$issue->{issues}} > 2) {
+    $main::log->err ("Found more than 2 issues for Bug ID $bugid") if $main::log;
+    
+    return "Found more than 2 issues for Bug ID $bugid";
+  } elsif (@{$issue->{issues}} == 2) {
+    my ($issueNum0, $issueNum1, $projectName0, $projectName1);
+    
+    if ($issue->{issues}[0]{key} =~ /(.*)-(\d+)/) {
+      $projectName0 = $1;
+      $issueNum0    = $2;
+    } # if
+    
+    if ($issue->{issues}[1]{key} =~ /(.*)-(\d+)/) {
+      $projectName1 = $1;
+      $issueNum1    = $2;
+    } # if
+    
+    if ($issueNum0 < $issueNum1) {
+      $issueID = $issue->{issues}[1]{key};
+    } # if
+    
+    # Let's mark them as clones. See if this clone link already exists...
+    my $alreadyCloned;
+    
+    for (getIssueLinks ($issueID, 'Cloners')) {
+      my $inwardIssue  = $_->{inwardIssue}{key}  || '';
+      my $outwardIssue = $_->{outwardIssue}{key} || '';
+      
+      if ("$projectName0-$issueNum0" eq $inwardIssue  ||
+          "$projectName0-$issueNum0" eq $outwardIssue ||
+          "$projectName1-$issueNum1" eq $inwardIssue  ||
+          "$projectName1-$issueNum1" eq $outwardIssue) {
+         $alreadyCloned = 1;
+         
+         last;
+      } # if
+    } # for
+
+    unless ($alreadyCloned) {
+      my $result = linkIssues ("$projectName0-$issueNum0", 'Cloners', "$projectName1-$issueNum1");
+    
+      return $result if $result =~ /Unable to/;
+    
+      $main::log->msg ($result) if $main::log;
+    } # unless
+  } # if
+
+  if ($issueID) {
+    $main::log->msg ("Found JIRA issue $issueID for Bug $bugid") if $main::log;
+  
+    #$cache{$bugid} = $issueID;
+      
+    #return $cache{$bugid};
+    return $issueID;
+  } else {
+    my $status = $bugmap{$bugid} ? 'Future JIRA Issue'
+                                 : "Unable to find a JIRA issue for Bug $bugid";
+    
+    # Here we put this bugid into the cache but instead of a the JIRA issue
+    # id we put the bugid. This will stop us from adding up multiple hits on
+    # this bugid.
+    #$cache{$bugid} = $bugid;
+
+    return $status;
+  } # if
+} # findJIRA
+
+sub findIssues (;$@) {
+  my ($condition, @fields) = @_;
+  
+  push @fields, '*all' unless @fields;
+  
+  $findQuery{jql}        = $condition || '';
+  $findQuery{startAt}    = 0;
+  $findQuery{maxResults} = 1;
+  $findQuery{fields}     = join ',', @fields;
+  
+  return;
+} # findIssues
+
+sub getNextIssue () {
+  my $result;
+  
+  eval {$result = $jira->GET ('/search/', \%findQuery)};
+  
+  $findQuery{startAt}++;
+  
+  # Move id and key into fields
+  return unless @{$result->{issues}};
+  
+  $result->{issues}[0]{fields}{id} = $result->{issues}[0]{id};
+  $result->{issues}[0]{fields}{key} = $result->{issues}[0]{key};
+    
+  return %{$result->{issues}[0]{fields}};
+} # getNextIssue
+
+sub getIssues (;$$$@) {
+  my ($condition, $start, $max, @fields) = @_;
+  
+  push @fields, '*all' unless @fields;
+  
+  my ($result, %query);
+  
+  $query{jql}        = $condition || '';
+  $query{startAt}    = $start     || 0;
+  $query{maxResults} = $max       || 50;
+  $query{fields}     = join ',', @fields;
+  
+  eval {$result = $jira->GET ('/search/', \%query)};
+
+  # We sometimes get an error here when $result->{issues} is undef.
+  # I suspect this is when the number of issues just happens to be
+  # an even number like on a $query{maxResults} boundry. So when
+  # $result->{issues} is undef we assume it's the last of the issues.
+  # (I should really verify this).
+  if ($result->{issues}) {
+    return @{$result->{issues}};
+  } else {
+    return;
+  } # if
+} # getIssues
+
+sub getIssue ($;@) {
+  my ($issue, @fields) = @_;
+  
+  my $fields = @fields ? "?fields=" . join ',', @fields : '';
+
+  return $jira->GET ("/issue/$issue$fields");
+} # getIssue
+
+sub getIssueLinkTypes () {
+  my $issueLinkTypes = $jira->GET ('/issueLinkType/');
+  
+  map {push @issueLinkTypes, $_->{name}} @{$issueLinkTypes->{issueLinkTypes}};
+  
+  return @issueLinkTypes
+} # getIssueLinkTypes
+
+sub linkIssues ($$$) {
+  my ($from, $type, $to) = @_;
+  
+  unless (@issueLinkTypes) {
+    getIssueLinkTypes;
+  } # unless
+  
+  unless (grep {$type eq $_} @issueLinkTypes) {
+    $main::log->err ("Type $type is not a valid issue link type\nValid types include:\n\t" 
+               . join "\n\t", @issueLinkTypes) if $main::log;
+               
+    return "Unable to $type link $from -> $to";           
+  } # unless  
+  
+  my %link = (
+    inwardIssue  => {
+      key        => $from,
+    },
+    type         => {
+      name       => $type,
+    },
+    outwardIssue => {
+      key        => $to,
+    },
+    comment      => {
+      body       => "Link ported as part of the migration from Bugzilla: $from <-> $to",
+    },
+  );
+  
+  $main::total{'IssueLinks Added'}++;
+  
+  if ($main::opts{exec}) {
+    eval {$jira->POST ("/issueLink", undef, \%link)};
+    
+    if ($@) {
+      return "Unable to $type link $from -> $to\n$@";
+    } else {
+      return "Made $type link $from -> $to";
+    } # if
+  } else {
+    return "Would have $type linked $from -> $to";
+  } # if
+} # linkIssue
+
+sub getRemoteLink ($;$) {
+  my ($jiraIssue, $id) = @_;
+  
+  $id //= '';
+  
+  my $result;
+  
+  eval {$result = $jira->GET ("/issue/$jiraIssue/remotelink/$id")};
+  
+  return if $@;
+  
+  my %remoteLinks;
+
+  if (ref $result eq 'ARRAY') {
+    map {$remoteLinks{$_->{id}} = $_->{object}{title}} @$result;  
+  } else {
+    $remoteLinks{$result->{id}} = $result->{object}{title};
+  } # if
+    
+  return \%remoteLinks;
+} # getRemoteLink
+
+sub getRemoteLinks (;$) {
+  my ($bugid) = @_;
+  
+  $jiradb = openJIRADB unless $jiradb;
+  
+  my $statement = 'select url from remotelink';
+
+  $statement .= " where url like 'http://bugs%'";  
+  $statement .= " and url like '%$bugid'" if $bugid; 
+  $statement .= " group by issueid desc";
+  
+  my $sth = $jiradb->prepare ($statement);
+  
+  _checkDBError 'Unable to prepare statement', $statement;
+  
+  $sth->execute;
+  
+  _checkDBError 'Unable to execute statement', $statement;
+
+  my %bugids;
+  
+  while (my $record = $sth->fetchrow_array) {
+    if ($record =~ /(\d+)$/) {
+      $bugids{$1} = 1;
+    } # if 
+  } # while
+  
+  return keys %bugids;
+} # getRemoteLinks
+
+sub findRemoteLinkByBugID (;$) {
+  my ($bugid) = @_;
+  
+  my $condition = 'where issueid = jiraissue.id and jiraissue.project = project.id';
+  
+  if ($bugid) {
+    $condition .= " and remotelink.url like '%id=$bugid'";
+  } # unless
+  
+  $jiradb = openJIRADB unless $jiradb;
+
+  my $statement = <<"END";
+select 
+  remotelink.id, 
+  concat (project.pkey, '-', issuenum) as issue,
+  relationship
+from
+  remotelink,
+  jiraissue,
+  project
+$condition
+END
+
+  my $sth = $jiradb->prepare ($statement);
+  
+  _checkDBError 'Unable to prepare statement', $statement;
+  
+  $sth->execute;
+  
+  _checkDBError 'Unable to execute statement', $statement;
+  
+  my @records;
+  
+  while (my $row = $sth->fetchrow_hashref) {
+    $row->{bugid} = $bugid;
+        
+    push @records, $row;
+  } # while
+  
+  return \@records;
+} # findRemoteLinkByBugID
+
+sub promoteBug2JIRAIssue ($$$$) {
+  my ($bugid, $jirafrom, $jirato, $relationship) = @_;
+
+  my $result = linkIssues $jirafrom, $relationship, $jirato;
+        
+  return $result if $result =~ /Unable to link/;
+  
+  $main::log->msg ($result . " (BugID $bugid)") if $main::log;
+  
+  for (@{findRemoteLinkByBugID $bugid}) {
+    my %record = %$_;
+    
+    $result = removeRemoteLink ($record{issue}, $record{id});
+    
+    # We may not care if we couldn't remove this link because it may have been
+    # removed by a prior pass.
+    return $result if $result =~ /Unable to remove link/;
+    
+    if ($main::log) {
+      $main::log->msg ($result) unless $result eq '';
+    } # if
+  } # for
+  
+  return $result;
+} # promoteBug2JIRAIssue
+
+sub addRemoteLink ($$$) {
+  my ($bugid, $relationship, $jiraIssue) = @_;
+  
+  my $bug = getBug $bugid;
+  
+  # Check to see if this Bug ID already exists on this JIRA Issue, otherwise
+  # JIRA will duplicate it! 
+  my $remoteLinks = getRemoteLink $jiraIssue;
+  
+  for (keys %$remoteLinks) {
+    if ($remoteLinks->{$_} =~ /Bug (\d+)/) {
+      return "Bug $bugid is already linked to $jiraIssue" if $bugid == $1;
+    } # if
+  } # for
+  
+  # Note this globalid thing is NOT working! ALl I see is null in the database
+  my %remoteLink = (
+#    globalid     => "system=http://bugs.audience.com/show_bug.cgi?id=$bugid",
+#    application  => {
+#      type       => 'Bugzilla',
+#      name       => 'Bugzilla',
+#    },
+    relationship => $relationship, 
+    object       => {
+      url        => "http://bugs.audience.com/show_bug.cgi?id=$bugid",
+      title      => "Bug $bugid",
+      summary    => $bug->{short_desc},
+      icon       => {
+        url16x16 => 'http://bugs.audience.local/favicon.png',
+        title    => 'Bugzilla Bug',
+      },
+    },
+  );
+  
+  $main::total{'RemoteLink Added'}++;
+  
+  if ($main::opts{exec}) {
+    eval {$jira->POST ("/issue/$jiraIssue/remotelink", undef, \%remoteLink)};
+  
+    return $@;
+  } else {
+    return "Would have linked $bugid -> $jiraIssue";
+  } # if
+} # addRemoteLink
+
+sub removeRemoteLink ($;$) {
+  my ($jiraIssue, $id) = @_;
+  
+  $id //= '';
+  
+  my $remoteLinks = getRemoteLink ($jiraIssue, $id);
+  
+  for (keys %$remoteLinks) {
+    my $result;
+    
+    $main::total{'RemoteLink Removed'}++;
+  
+    if ($main::opts{exec}) {
+      eval {$result = $jira->DELETE ("/issue/$jiraIssue/remotelink/$_")};
+
+      if ($@) {  
+        return "Unable to remove remotelink $jiraIssue ($id)\n$@" if $@;
+      } else {
+        my $bugid;
+        
+        if ($remoteLinks->{$_} =~ /(\d+)/) {
+          return "Removed remote link $jiraIssue (Bug ID $1)";
+        } # if
+      } # if
+      
+      $main::total{'Remote Links Removed'}++;
+    } else {
+      if ($remoteLinks->{$_} =~ /(\d+)/) {
+        return "Would have removed remote link $jiraIssue (Bug ID $1)";
+      } # if
+    } # if
+  } # for  
+} # removeRemoteLink
+
+sub getIssueLinks ($;$) {
+  my ($issue, $type) = @_;
+  
+  my @links = getIssue ($issue, ('issuelinks'));
+  
+  my @issueLinks;
+
+  for (@{$links[0]->{fields}{issuelinks}}) {
+     my %issueLink = %$_;
+     
+     next if ($type && $type ne $issueLink{type}{name});
+     
+     push @issueLinks, \%issueLink;  
+  }
+  
+  return @issueLinks;
+} # getIssueLinks
+
+sub getIssueWatchers ($) {
+  my ($issue) = @_;
+  
+  my $watchers;
+  
+  eval {$watchers = $jira->GET ("/issue/$issue/watchers")};
+  
+  return if $@;
+  
+  # The watcher information returned by the above is incomplete. Let's complete
+  # it.
+  my @watchers;
+  
+  for (@{$watchers->{watchers}}) {
+    my $user;
+    
+    eval {$user = $jira->GET ("/user?username=$_->{key}")};
+    
+    unless ($@) {
+      push @watchers, $user;
+    } else {
+      if ($main::log) {
+        $main::log->err ("Unable to find user record for $_->{name}")
+          unless $_->{name} eq 'jira-admin';
+      }# if
+    } # unless
+  } # for
+  
+  return @watchers;
+} # getIssueWatchers
+
+sub updateIssueWatchers ($%) {
+  my ($issue, %watchers) = @_;
+
+  my $existingWatchers;
+  
+  eval {$existingWatchers = $jira->GET ("/issue/$issue/watchers")};
+  
+  return "Unable to get issue $issue\n$@" if $@;
+  
+  for (@{$existingWatchers->{watchers}}) {
+    # Cleanup: Remove the current user from the watchers list.
+    # If he's on the list then remove him.
+    if ($_->{name} eq $jira->{username}) {
+      $jira->DELETE ("/issue/$issue/watchers?username=$_->{name}");
+      
+      $main::total{"Admins destroyed"}++;
+    } # if
+    
+    # Delete any matching watchers
+    delete $watchers{lc ($_->{name})} if $watchers{lc ($_->{name})};
+  } # for
+
+  return '' if keys %watchers == 0;
+  
+  my $issueUpdated;
+  
+  for (keys %watchers) {
+    if ($main::opts{exec}) {
+      eval {$jira->POST ("/issue/$issue/watchers", undef, $_)};
+    
+      if ($@) {
+        $main::log->warn ("Unable to add user $_ as a watcher to JIRA Issue $issue") if $main::log;
+      
+        $main::total{'Watchers skipped'}++;
+      } else {
+        $issueUpdated = 1;
+        
+        $main::total{'Watchers added'}++;
+      } # if
+    } else {
+      $main::log->msg ("Would have added user $_ as a watcher to JIRA Issue $issue") if $main::log;
+      
+      $main::total{'Watchers that would have been added'}++;
+    } # if
+  } # for
+  
+  $main::total{'Issues updated'}++ if $issueUpdated;
+  
+  return '';
+} # updateIssueWatchers
+
+sub getUsersGroups ($) {
+  my ($username) = @_;
+  
+  my ($result, %query);
+  
+  %query = (
+    username => $username,
+    expand   => 'groups',
+  );
+  
+  eval {$result = $jira->GET ('/user/', \%query)};
+  
+  my @groups;
+  
+  for (@{$result->{groups}{items}}) {
+    push @groups, $_->{name};
+  } # for
+  
+  return @groups;
+} # getusersGroups
+
+sub updateUsersGroups ($@) {
+  my ($username, @groups) = @_;
+  
+  my ($result, @errors);
+  
+  my @oldgroups = getUsersGroups $username;
+  
+  # We can't always add groups to the new user due to either the group not being
+  # in the new LDAP directory or we are unable to see it. If we attempt to JIRA
+  # will try to add the group and we don't have write permission to the 
+  # directory. So we'll just return @errors and let the caller deal with it.  
+  for my $group (@groups) {
+    next if grep {$_ eq $group} @oldgroups;
+    
+    eval {$result = $jira->POST ('/group/user', {groupname => $group}, {name => $username})};
+  
+    push @errors, $@ if $@;  
+  } # for
+  
+  return @errors;
+} # updateUsersGroups
+
+sub copyGroupMembership ($$) {
+  my ($from_username, $to_username) = @_;
+  
+  return updateUsersGroups $to_username, getUsersGroups $from_username;
+} # copyGroupMembership
+
+sub updateColumn ($$$%) {
+  my ($table, $oldvalue, $newvalue, %info) = @_;
+  
+  # UGH! Sometimes values need to be quoted
+  $oldvalue = quotemeta $oldvalue;
+  $newvalue = quotemeta $newvalue;
+  
+  my $condition  =  "$info{column} = '$oldvalue'";
+     $condition .= " and $info{condition}" if $info{condition};
+  my $statement  = "update $table set $info{column} = '$newvalue' where $condition";
+
+  my $nbrRows = count $table, $condition;
+  
+  if ($nbrRows) {
+    if ($main::opts{exec}) {
+      $main::total{'Rows updated'}++;
+    
+      $jiradb->do ($statement);
+      
+      _checkDBError 'Unable to execute statement', $statement;
+    } else {
+      $main::total{'Rows would be updated'}++;
+
+      $main::log->msg ("Would have executed $statement") if $main::log;
+    } # if
+  } # if 
+  
+  return $nbrRows;
+} # updateColumn
+
+sub renameUsers (%) {
+  my (%users) = @_;
+
+  for my $olduser (sort keys %users) {
+    my $newuser = $users{$olduser};
+    
+    $main::log->msg ("Renaming $olduser -> $newuser") if $main::log;
+    display ("Renaming $olduser -> $newuser");
+    
+    if ($main::opts{exec}) {
+      $main::total{'Users renamed'}++;
+    } else {
+      $main::total{'Users would be updated'}++;
+    } # if
+    
+    for my $table (sort keys %tables) {
+      $main::log->msg ("\tTable: $table Column: ", 1) if $main::log;
+      
+      my @columns = @{$tables{$table}};
+      
+      for my $column (@columns) {
+        my %info = %$column;
+        
+        $main::log->msg ("$info{column} ", 1) if $main::log;
+        
+        my $rowsUpdated = updateColumn ($table, $olduser, $newuser, %info);
+        
+        if ($rowsUpdated) {
+          my $msg  = " $rowsUpdated row";
+             $msg .= 's' if $rowsUpdated > 1;
+             $msg .= ' would have been' unless $main::opts{exec};
+             $msg .= ' updated';
+             
+          $main::log->msg ($msg, 1) if $main::log;
+        } # if
+      } # for
+      
+      $main::log->msg ('') if $main::log;
+    } # for
+    
+    if (my @result = copyGroupMembership ($olduser, $newuser)) {
+      # Skip errors of the form 'Could not add user... group is read-only
+      @result = grep {!/Could not add user.*group is read-only/} @result;
+      
+      if ($main::log) {
+        $main::log->err ("Unable to copy group membership from $olduser -> $newuser\n@result", 1) if @result;
+      } # if
+    } # if
+  } # for
+  
+  
+  return $main::log ? $main::log->errors : 0;
+} # renameUsers
+
+1;
diff --git a/JIRA/updateWatchLists.pl b/JIRA/updateWatchLists.pl
new file mode 100644 (file)
index 0000000..5a5c4a0
--- /dev/null
@@ -0,0 +1,170 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+=pod
+
+=head1 NAME updateWatchLists.pl
+
+Copy CC lists from Bugzilla -> JIRA
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #1 $
+
+=item Created
+
+Thu Mar 20 10:11:53 PDT 2014
+
+=item Modified
+
+$Date: 2014/05/23 $
+
+=back
+
+=head1 SYNOPSIS
+
+ Updates JIRA watchlists by copying the CC list information from Bugzilla
+  $ updateWatchLists.pl [-login <login email>] [-products product1,
+                        product2,...] [-[no]exec]
+                        [-verbose] [-help] [-usage]
+
+  Where:
+
+    -v|erbose:       Display progress output
+    -he|lp:          Display full help
+    -usa|ge:         Display usage
+    -[no]e|xec:      Whether or not to update JIRA. -noexec says only 
+                     tell me what you would have updated.
+    -use|rname:      Username to log into JIRA with (Default: jira-admin)
+    -p|assword:      Password to log into JIRA with (Default: jira-admin's 
+                     password)
+    -bugzillaserver: Machine where Bugzilla lives (Default: bugs-dev)
+    -jiraserver:     Machine where Jira lives (Default: jira-dev)
+    -bugi|ds:        Comma separated list of BugIDs to process
+    -f|ile:          File of BugIDs, one per line
+
+=head1 DESCRIPTION
+
+This script updates JIRA watchlists by copying the CC List information from
+Bugzilla to JIRA.
+
+=cut
+
+use FindBin;
+use lib "$FindBin::Bin/lib";
+
+$| = 1;
+
+use DBI;
+use Display;
+use Logger;
+use TimeUtils;
+use Utils;
+use JIRAUtils;
+use BugzillaUtils;
+use JIRA::REST;
+
+use Getopt::Long; 
+use Pod::Usage;
+
+# Login should be the email address of the bugzilla account which has 
+# priviledges to create products and components
+our %opts = (
+  exec           => 0,
+  bugzillaserver => $ENV{BUGZILLASERVER} || 'bugs-dev',
+  jiraserver     => $ENV{JIRASERVER}     || 'jira-dev:8081',
+  username       => 'jira-admin',
+  password       => 'jira-admin',
+  usage          => sub { pod2usage },
+  help           => sub { pod2usage (-verbose => 2)},
+  verbose        => sub { set_verbose },
+);
+
+our ($log, %total);
+
+my ($bugzilla, $jira);
+
+sub main () {
+  my $startTime = time;
+  
+  GetOptions (
+    \%opts,
+    'verbose',
+    'usage',
+    'help',
+    'exec!',
+    'quiet',
+    'username=s',
+    'password=s',
+    'bugids=s@',
+    'file=s',
+    'jiraserver=s',
+    'bugzillaserver=s',
+  ) or pod2usage;
+  
+  $log = Logger->new;
+  
+  if ($opts{file}) {
+    open my $file, '<', $opts{file} 
+      or die "Unable to open $opts{file} - $!";
+      
+    $opts{bugids} = [<$file>];
+    
+    chomp @{$opts{bugids}};
+  } else {
+    my @bugids;
+    
+    push @bugids, (split /,/, join (',', $_)) for (@{$opts{bugids}}); 
+  
+    $opts{bugids} = [@bugids];
+  } # if
+  
+  pod2usage 'Must specify -bugids <bugid>[,<bugid>,...] or -file <filename>'
+    unless $opts{bugids};
+
+  openBugzilla $opts{bugzillaserver}
+    or $log->err ("Unable to connect to $opts{bugzillaserver}", 1);
+    
+  Connect2JIRA ($opts{username}, $opts{password}, $opts{jiraserver})
+    or $log->err ("Unable to connect to $opts{jiraserver}", 1);
+  
+  for (@{$opts{bugids}}) {
+    my $issue = findIssue $_;
+    
+    if ($issue =~ /^Future JIRA Issue/ or $issue =~ /^Unable to find/) {
+      $log->msg ($issue);
+    } else {
+      my %watchers = getWatchers $_;
+      
+      $log->msg ('Found ' . scalar (keys %watchers) . " watchers for JIRA Issue $issue");
+      
+      my $result = updateIssueWatchers ($issue, %watchers);
+      
+      if ($result =~ /^Unable to/) {
+        $total{'Missing JIRA Issues'}++;
+        
+        $log->err ($result);
+      } else {
+        $total{'Issues updated'}++;
+      } # if
+    } # if
+  } # for
+
+  display_duration $startTime, $log;
+  
+  Stats (\%total, $log) unless $opts{quiet};
+  
+  return  0;
+} # main
+
+exit main;
diff --git a/Perforce/getPicture.conf b/Perforce/getPicture.conf
new file mode 100644 (file)
index 0000000..51ea679
--- /dev/null
@@ -0,0 +1,21 @@
+################################################################################
+#
+# File:         getPicture.conf
+# Description:  Configuration file for getPicture.pl
+# Author:       Andrew DeFaria <Andrew@ClearSCM.com>
+# Version:      1.0
+# Created:      Fri Oct  3 18:16:26 PDT 2014
+# Modified:     $Date: 2014/10/03 18:17:20 $
+# Language:     Conf
+#
+# This file contains configurable parameters that are unique to your site that
+# identify the LDAP parms needed to retrieve users pictures from Active 
+# Directory. It is assumed that thumbnailPhoto is the attribute name and that
+# the user can be found by their unique uid.
+#
+################################################################################
+AD_HOST:     ad-vip.audience.local
+AD_PORT:     389
+AD_BINDDN:   CN=jenkins cm,OU=Service Accounts,DC=audience,DC=local
+AD_BINDPW:   j55ln8Y6
+AD_BASEDN:   DC=audience,DC=local
\ No newline at end of file
diff --git a/Perforce/getPicture.pl b/Perforce/getPicture.pl
new file mode 100755 (executable)
index 0000000..5a36c8c
--- /dev/null
@@ -0,0 +1,203 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+
+=pod
+
+=head1 NAME $File: //AudEngr/Import/VSS/ReleaseEng/Dev/Perforce/getPicture.pl $
+
+Retrieve thumbnailPhoto for the userid from Active Directory
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #1 $
+
+=item Created
+
+Fri Oct  3 18:16:26 PDT 2014
+
+=item Modified
+
+$Date: 2015/03/03 $
+
+=back
+
+=head1 DESCRIPTION
+
+This script will take a userid and search the Active Directory for the user and
+return an image file if the user has an image associated with his 
+thumbnailPhoto attribute.
+
+This can be configured into Perforce Swarn as documented:
+
+http://www.perforce.com/perforce/doc.current/manuals/swarm/admin.avatars.html
+
+One would use something like
+
+  // this block shoudl be a peer of 'p4'
+  'avatars' => array(
+    'http_url'  => 'http://<server>/cgi-bin/getPicture.pl?userid={user}'
+    'https_url' => 'http://<server>/cgi-bin/getPicture.pl?userid={user}',
+  ),
+
+=cut
+
+use FindBin;
+use Getopt::Long;
+use Pod::Usage;
+use Net::LDAP;
+use CGI qw (:standard);
+
+# Interpolate variable in str (if any) from %opts
+sub interpolate ($%) {
+  my ($str, %opts) = @_;
+
+  # Since we wish to leave undefined $var references in tact the following while
+  # loop would loop indefinitely if we don't change the variable. So we work
+  # with a copy of $str changing it always, but only changing the original $str
+  # for proper interpolations.
+  my $copyStr = $str;
+
+  while ($copyStr =~ /\$(\w+)/) {
+    my $var = $1;
+
+    if (exists $opts{$var}) {
+      $str     =~ s/\$$var/$opts{$var}/;
+      $copyStr =~ s/\$$var/$opts{$var}/;
+    } elsif (exists $ENV{$var}) {
+      $str     =~ s/\$$var/$ENV{$var}/;
+      $copyStr =~ s/\$$var/$ENV{$var}/;
+    } else {
+     $copyStr =~ s/\$$var//;
+  } # if
+ } # while
+
+ return $str;
+} # interpolate
+
+sub _processFile ($%) {
+  my ($configFile, %opts) = @_;
+  
+  while (<$configFile>) {
+    chomp;
+
+    next if /^\s*[\#|\!]/;    # Skip comments
+
+    if (/\s*(.*?)\s*[:=]\s*(.*)\s*/) {
+      my $key   = $1;
+      my $value = $2;
+
+      # Strip trailing spaces
+      $value =~ s/\s+$//;
+
+      # Interpolate
+      $value = interpolate $value, %opts;
+
+      if ($opts{$key}) {
+        # If the key exists already then we have a case of multiple values for 
+        # the same key. Since we support this we need to replace the scalar
+        # value with an array of values...
+        if (ref $opts{$key} eq "ARRAY") {
+          # It's already an array, just add to it!
+          push @{$opts{$key}}, $value;
+        } else {
+          # It's not an array so make it one
+          my @a;
+
+          push @a, $opts{$key};
+          push @a, $value;
+          $opts{$key} = \@a;
+        } # if
+      } else {
+        # It's a simple value
+        $opts{$key} = $value;
+      }  # if
+    } # if
+  } # while
+  
+  return %opts;
+} # _processFile
+
+sub GetConfig ($) {
+  my ($filename) = @_;
+
+  my %opts;
+
+  open my $configFile, '<', $filename
+    or die "Unable to open config file $filename";
+
+  %opts = _processFile $configFile;
+
+  close $configFile;
+
+  return %opts;
+} # GetConfig
+
+sub checkLDAPError ($$) {
+  my ($msg, $result) = @_;
+  
+  my $code = $result->code;
+  
+  die "$msg (Error $code)\n" . $result->error if $code;
+} # checkLDAPError
+
+my ($confFile) = ($FindBin::Script =~ /(.*)\.pl$/);
+    $confFile = "$FindBin::Bin/$confFile.conf";
+
+my %opts = GetConfig ($confFile);
+
+## Main
+$| = 1;
+
+GetOptions (
+  \%opts,
+  'AD_HOST=s',
+  'AD_PORT=s',
+  'AD_BINDDN=s',
+  'AD_BINDPW=s',
+  'AD_BASEDN=s',
+  'userid=s', 
+) or pod2usage;
+
+$opts{userid} = param 'userid' unless $opts{userid};
+
+pod2usage "Usage getPicture.pl [userid=]<userid>\n" unless $opts{userid};
+
+my $ldap = Net::LDAP->new (
+  $opts{AD_HOST}, (
+    host   => $opts{AD_HOST},
+    port   => $opts{AD_PORT},
+    basedn => $opts{AD_BASEDN},
+    binddn => $opts{AD_BINDDN},
+    bindpw => $opts{AD_BINDPW},
+  ),
+) or die $@;
+
+my $result = $ldap->bind (
+  dn       => $opts{AD_BINDDN},
+  password => $opts{AD_BINDPW},
+) or die "Unable to bind\n$@";
+
+checkLDAPError ('Unable to bind', $result);
+
+$result = $ldap->search (
+  base   => $opts{AD_BASEDN},
+  filter => "sAMAccountName=$opts{userid}",
+);
+
+checkLDAPError ('Unable to search', $result);
+
+my @entries = ($result->entries);
+
+if ($entries[0]) {
+  print header 'image/jpeg';
+  print $entries[0]->get_value ('thumbnailPhoto');  
+} # if
diff --git a/Perforce/lib/Perforce.pm b/Perforce/lib/Perforce.pm
new file mode 100644 (file)
index 0000000..a2d5897
--- /dev/null
@@ -0,0 +1,394 @@
+package Perforce;
+
+use strict;
+use warnings;
+
+use Carp;
+use File::Basename;
+use File::Temp;
+
+use P4;
+use Authen::Simple::LDAP;
+
+use Display;
+use GetConfig;
+use Utils;
+
+our $VERSION  = '$Revision: 2.23 $';
+   ($VERSION) = ($VERSION =~ /\$Revision: (.*) /);
+
+my $p4config   = $ENV{P4_CONF}   || dirname (__FILE__) . '/../etc/p4.conf';
+my $ldapconfig = $ENV{LDAP_CONF} || dirname (__FILE__) . '/../etc/LDAP.conf';
+
+my %P4OPTS   = GetConfig $p4config   if -r $p4config;
+my %LDAPOPTS = GetConfig $ldapconfig if -r $ldapconfig;
+
+my $serviceUser = 'shared';
+my ($domain, $password);
+my $defaultPort = 'perforce:1666';
+my $p4tickets   = $^O =~ /win/i ? 'C:/Program Files/Devops/Perforce/p4tickets'
+                                : '/opt/audience/perforce/p4tickets';
+                                
+my $keys;
+
+# If USERDOMAIN is set and equal to audience then set $domain to ''. This will
+# use the Audience domain settings in LDAP.conf.
+if ($ENV{USERDOMAIN}) {
+  if (lc $ENV{USERDOMAIN} eq 'audience') {
+    $domain = '';
+  } else {
+    $domain = $ENV{USERDOMAIN}
+  } # if
+} # if
+
+sub new (;%) {
+  my ($class, %parms) = @_;
+  
+  my $self = bless {}, $class;
+  
+  $self->{P4USER}   = $parms{username} || $P4OPTS{P4USER}   || $ENV{P4USER}   || $serviceUser;
+  $self->{P4PASSWD} = $parms{password} || $P4OPTS{P4PASSWD} || $ENV{P4PASSWD} || undef;
+  $self->{P4CLIENT} = $parms{p4client} || $P4OPTS{P4CLIENT} || $ENV{P4CLIENT} || undef;
+  $self->{P4PORT}   = $parms{p4port}   || $ENV{P4PORT}    || $defaultPort;
+
+  $self->{P4}       = $self->connect (%parms);
+  
+  return $self; 
+} # new
+
+sub errors ($;$) {
+  my ($self, $cmd, $exit) = @_;
+
+  my $msg    = "Unable to run \"p4 $cmd\"";
+  my $errors = $self->{P4}->ErrorCount;
+
+  error "$msg\n" . $self->{P4}->Errors, $exit if $errors; 
+
+  return $errors;
+} # errors
+
+sub connect () {
+  my ($self) = @_;
+  
+  $self->{P4} = P4->new;
+  
+  $self->{P4}->SetUser     ($self->{P4USER});
+  $self->{P4}->SetClient   ($self->{P4CLIENT}) if $self->{P4CLIENT};
+  $self->{P4}->SetPort     ($self->{P4PORT});
+  $self->{P4}->SetPassword ($self->{P4PASSWD}) unless $self->{P4USER} eq $serviceUser;
+
+  verbose_nolf "Connecting to Perforce server $self->{P4PORT}...";
+  $self->{P4}->Connect or croak "Unable to connect to Perforce Server\n";
+  verbose 'done';
+  
+  verbose_nolf "Logging in as $self->{P4USER}\@$self->{P4PORT}...";
+
+  unless ($self->{P4USER} eq $serviceUser) {
+    $self->{P4}->RunLogin;
+
+    $self->errors ('login', $self->{P4}->ErrorCount);
+  } else {
+    $ENV{P4TICKETS} = $p4tickets if $self->{P4USER} eq $serviceUser;
+  } # unless
+
+  verbose 'done';
+
+  return $self->{P4};
+} # connect
+
+sub _authenticateUser ($$$$) {
+  my ($self, $domain, $username, $p4client) = @_;
+  
+  $domain .= '_' unless $domain eq '';
+  
+  # Connect to LDAP
+  my $ad = Authen::Simple::LDAP->new (
+    host   => $LDAPOPTS{"${domain}AD_HOST"},
+    basedn => $LDAPOPTS{"${domain}AD_BASEDN"},
+    port   => $LDAPOPTS{"${domain}AD_PORT"},
+    filter => $LDAPOPTS{"${domain}AD_FILTER"},
+  ) or croak $@;
+  
+  # Read the password from <stdin> and truncate the newline - unless we already
+  # read in the password
+  unless ($password) {
+    if (-t STDIN) {
+      $password = GetPassword;
+    } else {
+      $password = <STDIN>;
+      
+      chomp $password;
+    } # if
+  } # unless
+  
+  # Special handling of "shared" user
+  if ($username eq 'shared') {
+    my $sharedAcl = "$FindBin::Bin/sharedAcl.txt";
+    
+    croak "Unable to find file $sharedAcl" unless -f $sharedAcl;
+    
+    open my $sharedAcls, '<', $sharedAcl
+      or croak "Unable to open $sharedAcl - $!";
+      
+    chomp (my @acls = <$sharedAcls>);
+    
+    close $sharedAcls;
+    
+    for (@acls) {
+      if (/\*$/) {
+        chop;
+        
+        exit if $p4client =~ /$_/;
+      } else {
+        exit if $_ eq $p4client;
+      } # if
+    } # for
+  } # if
+
+  # Connect to Perforce
+  $self->connect unless $self->{P4};
+  
+  # Must be a valid Perforce user  
+  return unless $self->getUser ($username);
+  
+  # And supply a valid username/password
+  return $ad->authenticate ($username, $password);
+} # _authenticateUser
+
+sub authenticateUser ($;$) {
+  my ($self, $username, $p4client) = @_;
+  
+=pod
+  # If $domain is set to '' then we'll check Audience's LDAP. 
+  # If $domain is not set (undef) then we'll try Knowles first, then Audience
+  # otherwise we will take $DOMAIN and look for those settings...
+  unless ($domain) {
+    unless ($self->_authenticateUser ('KNOWLES', $username, $p4client)) {
+      unless ($self->_authenticateUser ('', $username, $p4client)) {
+        return;
+      } # unless
+    } # unless
+  } else {
+    if ($domain eq '') {
+      unless ($self->_authenticateUser ('', $username, $p4client)) {
+        return;
+      } # unless
+    } else {
+      unless ($self->_authenticateUser ($domain, $username, $p4client)) {
+        return;
+      } # unless
+    } # if
+  } # unless
+=cut
+
+  return $self->_authenticateUser ('KNOWLES',  $username, $p4client);  
+  
+#  return 1;
+} # authenticateUser
+
+sub changes (;$%) {
+  my ($self, $args, %opts) = @_;
+
+  my $cmd = 'changes';
+
+  for (keys %opts) {
+    if (/from/i and $opts{to}) {
+        $args .= " $opts{$_},$opts{to}";
+        
+        delete $opts{to};
+    } else {
+      $args .= " $opts{$_}";
+    } # if
+  } # for
+  
+  my $changes = $self->{P4}->Run ($cmd, $args);
+  
+  return $self->errors ("$cmd $args") || $changes;
+} # changes
+
+sub job ($) {
+  my ($self, $job) = @_;
+  
+  my $jobs = $self->{P4}->IterateJobs ("-e $job");
+  
+  return $self->errors ("jobs -e $job") || $job;
+} # job
+
+sub comments ($) {
+  my ($self, $changelist) = @_;
+  
+  my $change = $self->{P4}->FetchChange ($changelist);
+  
+  return $self->errors ("change $changelist") || $change;
+} # comments
+
+sub files ($) {
+  my ($self, $changelist) = @_;
+  
+  my $files = $self->{P4}->Run ('files', "\@=$changelist");
+  
+  return $self->errors ("files \@=$changelist") || $files;
+} # files
+
+sub filelog ($;%) {
+  my ($self, $fileSpec, %opts) = @_;
+  
+  return $self->{P4}->RunFilelog ($fileSpec, %opts);
+} # filelog
+
+sub getRevision ($;$) {
+  my ($self, $filename, $revision) = @_;
+  
+  unless ($revision) {
+    if ($filename =~ /#/) {
+      ($filename, $revision) = split $filename, '#';
+    } else {
+      error "No revision specified in $filename";
+    
+      return;
+    } # if
+  } # unlessf
+
+  my @contents = $self->{P4}->RunPrint ("$filename#$revision");
+  
+  if ($self->{P4}->ErrorCount) {
+    $self->errors ("Print $filename#$revision");
+    
+    return;
+  } else {
+    return @contents;
+  } # if
+} # getRevision
+
+sub getUser (;$) {
+  my ($self, $user) = @_;
+  
+  $user //= $ENV{P4USER} || $ENV{USER};
+  
+  my $cmd  = 'user';
+  my @args = ('-o', $user);
+  
+  my $userRecs = $self->{P4}->Run ($cmd, @args);
+  
+  # Perforce returns an array of qualifying users. We only care about the first
+  # one. However if the username is invalid, Perforce still returns something 
+  # that looks like a user. We look to see if there is a Type field here which
+  # indicates that it's a valid user
+  if ($userRecs->[0]{Type}) {
+    return %{$userRecs->[0]};
+  } else {
+    return;
+  } # if
+} # getUser
+
+sub renameSwarmUser ($$) {
+  my ($self, $oldusername, $newusername) = @_;
+  
+  # We are turning this off because Perforce support says that just modifying
+  # the keys we do not update the indexing done in the Perforce Server/Database.
+  # So instead we have a PHP script (renameUser.php) which goes through the
+  # official, but still unsupported, "Swarm Record API" to change the usernames
+  # and call the object's method "save" which should perform the necessary
+  # reindexing... Stay tuned! :-)
+  #
+  # BTW One needs to run renameUser.php by hand as we do not do that here. 
+  return;
+  
+  $keys = $self->getKeys ('swarm-*') unless $keys;
+  
+  for (@$keys) {
+    my %key = %$_;
+    
+    if ($key{value} =~ /$oldusername/) {
+      $key{value} =~ s/\"$oldusername\"/\"$newusername\"/g;
+      $key{value} =~ s/\@$oldusername /\@$newusername /g;
+      $key{value} =~ s/\@$oldusername\./\@$newusername\./g;
+      $key{value} =~ s/\@$oldusername,/\@$newusername,/g;
+      $key{value} =~ s/ $oldusername / $newusername /g;
+      $key{value} =~ s/ $oldusername,/ $newusername,/g;
+      $key{value} =~ s/ $oldusername\./ $newusername\./g;
+      $key{value} =~ s/-$oldusername\"/-$newusername\"/g;
+      
+      my $cmd = 'key';
+      
+      display "Correcting key $key{key}";
+
+      my @result = $self->{P4}->Run ($cmd, $key{key}, $key{value});
+      
+      $self->errors ($cmd, $result[0]->{key} || 1);
+    } # if
+  } # for
+  
+  return;
+} # renameSwarmUser
+
+sub renameUser ($$) {
+  my ($self, $old, $new) = @_;
+  
+  my $cmd  = 'renameuser';
+  my @args = ("--from=$old", "--to=$new");
+  
+  $self->{P4}->Run ($cmd, @args);
+  
+  my $status = $self->errors (join ' ', $cmd, @args);
+  
+  return $status if $status;
+  
+#  return $self->renameSwarmUser ($old, $new);
+} # renameUser
+
+sub updateUser (%) {
+  my ($self, %user) = @_;
+  
+  # Trying to do this with P4Perl is difficult. First off the structure needs
+  # to be AOH and secondly you need to call SetUser to be the other user. That
+  # said you need to also specify -f to force the update (which means you must
+  # a admin (or superuser?) and I found no way to specify -f so I've reverted
+  # back to using p4 from the command line. I also don't like having to use
+  # a file here...
+  my $tmpfile     = File::Temp->new;
+  my $tmpfilename = $tmpfile->filename;
+  
+  print $tmpfile "User: $user{User}\n";
+  print $tmpfile "Email: $user{Email}\n";
+  print $tmpfile "Update: $user{Update}\n";
+  print $tmpfile "FullName: $user{FullName}\n";
+  
+  close $tmpfile;
+  
+  my @lines  = `p4 -p $self->{P4PORT} user -f -i < $tmpfilename`;
+  my $status = $?;
+
+  return wantarray ? @lines : join '', @lines;  
+} # updateUser
+
+sub getKeys (;$) {
+  my ($self, $filter) = @_;
+  
+  my $cmd = 'keys';
+  my @args;
+  
+  if ($filter) {
+    push @args, '-e';
+    push @args, $filter;
+  } # if
+  
+  my $keys = $self->{P4}->Run ($cmd, @args);
+  
+  $self->errors ($cmd . join (' ', @args), 1);
+  
+  return $keys; 
+} # getKeys
+
+sub key ($$) {
+  my ($self, $name, $value) = @_;
+  
+  my $cmd = 'key';
+  my @args = ($name, $value);
+  
+  $self->{P4}->Run ($cmd, @args);
+  
+  return $self->errors (join ' ', $cmd, @args);
+} # key
+
+1;
diff --git a/Perforce/renameUser.pl b/Perforce/renameUser.pl
new file mode 100755 (executable)
index 0000000..f542372
--- /dev/null
@@ -0,0 +1,224 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+
+=pod
+
+=head1 NAME RenameUser.pl
+
+Renames a Perforce user in Perforce
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #1 $
+
+=item Created
+
+Fri Oct 30 12:16:39 PDT 2015
+
+=item Modified
+
+$Date: $
+
+=back
+
+=head1 SYNOPSIS
+
+  $ RenameUser.pl [-oldusername <username> -newusername <username> |
+                  -file <filename>] [-p4port <p4port>]
+                  [-username <user>] [-password <password>]
+                  [-[no]exec]  
+                 
+                  [-verbose] [-debug] [-help] [-usage]
+
+  Where:
+
+    -o|ldusername:  Old username to rename
+    -ne|wusername:  New username
+    -f|ile:         File of usernames to process 
+                    (<oldusername> <newusername>)
+    -p|4port:       Perforce port (Default: perforce:1666)
+    -use|rname:     Username to log in as (Default: root)
+    -p|assword:     Password for -username (Defaul: <root's password>)
+    -[no]e|xec:     Whether or not to update the database
+
+    -v|erbose:      Display verbose info about actions being taken
+    -d|ebug:        Display debug info
+    -h|elp:         Display full documentation
+    -usa|ge:        Display usage
+
+Note that -file is a list of whitespace separated usernames, one per line listed
+as <oldusername> <newusername>
+
+=head1 DESCRIPTION
+
+This script will rename a Perforce user from -oldusername to -newusername. It
+will also update the users email address.
+
+=cut
+
+use Getopt::Long;
+use Pod::Usage;
+use FindBin;
+
+use lib "$FindBin::Bin/../lib";
+
+use CMUtils;
+use Display;
+use Logger;
+use Perforce;
+use Utils;
+use TimeUtils;
+
+my ($p4, $log, $keys);
+
+my %total;
+
+my %opts = (
+  p4port       => $ENV{P4PORT},
+  username     => $ENV{P4USER},
+  password     => $ENV{P4PASSWD},
+  verbose      => $ENV{VERBOSE}        || sub { set_verbose },
+  debug        => $ENV{DEBUG}          || sub { set_debug },
+  usage        => sub { pod2usage },
+  help         => sub { pod2usage (-verbose => 2)},
+);
+
+sub check4Dups (%) {
+  my (%users) = @_;
+  
+  my %newusers;
+  
+  for my $key (keys %users) {
+    my $value = $users{$key};
+    
+    if ($users{$value}) {
+      $log->warn ("$value exists as both a key and a value");
+    } else {
+      $newusers{$key} = $users{$key};
+    } # if
+  } # for
+  
+  return %newusers;
+} # check4Dups
+
+sub renameUsers (%) {
+  my (%users) = @_;
+
+  for my $olduser (keys %users) {
+    my $newuser = $users{$olduser};
+    
+    if ($opts{exec}) {
+      if ($p4->getUser ($olduser)) {
+        my $status = $p4->renameUser ($olduser, $newuser);
+      
+        unless ($status) {
+          $log->msg ("Renamed $olduser -> $newuser");
+          
+          $total{'Users renamed'}++;
+        } else {
+          $log->err ("Unable to rename $olduser -> $newuser");
+          
+          return 1;
+        } # unless
+      } else {
+        $total{'Non Perforce users'}++;
+        
+        $log->msg ("$olduser is not a Perforce user");
+        
+        next;
+      } # if
+    } else {
+      $total{'Users would be renamed'}++;
+        
+      next;
+    } # if
+
+    my %user = $p4->getUser ($newuser);
+    
+    $log->err ("Unable to retrieve user info for $newuser", 1) unless %user;
+    
+    my $email = getUserEmail ($newuser);
+    
+    if ($user{Email} ne $email) {
+      $user{Email} = $email;
+      
+      my $result = $p4->updateUser (%user);
+      
+      $log->err ("Unable to update user $newuser", 1) unless $result;
+      
+      $log->msg ("Updated ${newuser}'s email to $email");
+      
+      $total{'User email updated'}++;
+    } # if
+  } # for
+  
+  return $log->errors;
+} # renameUsers
+
+sub main () {
+  GetOptions (
+    \%opts,
+    'verbose',
+    'debug',
+    'usage',
+    'help',
+    'jiradbserver=s',
+    'username=s',
+    'password=s',
+    'file=s',
+    'oldusername=s',
+    'newusername=s',
+    'exec!',
+  ) or pod2usage;
+
+  $opts{debug}   = get_debug   if ref $opts{debug}   eq 'CODE';
+  $opts{verbose} = get_verbose if ref $opts{verbose} eq 'CODE';
+  
+  $log = Logger->new;
+  
+  if ($opts{username} && !$opts{password}) {
+    $opts{password} = GetPassword;
+  } # if
+
+  $p4 = Perforce->new (%opts);
+
+  my %users;
+
+  my $startTime = time;
+
+  if ($opts{oldusername} and $opts{newusername}) {
+    $opts{oldusername} = lc $opts{oldusername};
+    $opts{newusername} = lc $opts{newusername};
+    
+    $users{$opts{oldusername}} = $opts{newusername}; 
+  } elsif ($opts{file}) {
+    for (ReadFile $opts{file}) {
+      my ($olduser, $newuser) = split;
+      
+      $users{lc $olduser} = lc $newuser;
+    } # while
+  } else {
+    pod2usage "You must specify either -file or -oldname/-newname";
+  } # if
+
+  %users = check4Dups %users;
+  
+  my $status = renameUsers (%users);
+  
+  display_duration $startTime, $log;
+  
+  Stats \%total, $log;
+  
+  return $status;
+} # main
+
+exit main;
\ No newline at end of file
diff --git a/audience/JIRA/importComments.pl b/audience/JIRA/importComments.pl
deleted file mode 100644 (file)
index 6a1712c..0000000
+++ /dev/null
@@ -1,237 +0,0 @@
-#!/usr/bin/perl
-use strict;
-use warnings;
-
-=pod
-
-=head1 NAME importComments.pl
-
-This will import the comments from Bugzilla and update the corresponding JIRA
-Issues.
-
-=head1 VERSION
-
-=over
-
-=item Author
-
-Andrew DeFaria <Andrew@ClearSCM.com>
-
-=item Revision
-
-$Revision: #1 $
-
-=item Created
-
-Thu Mar 20 10:11:53 PDT 2014
-
-=item Modified
-
-$Date: 2014/05/23 $
-
-=back
-
-=head1 SYNOPSIS
-
-  $ importComments.pl [-bugzillaserver <bugshost>] [-login <login email>]
-                      [-jiraserver <server>]
-                      [-username <username>] [-password <password>] 
-                      [-bugids bugid,bugid,... | -file <filename>] 
-                      [-[no]exec]
-                      [-verbose] [-help] [-usage]
-
-  Where:
-
-    -v|erbose:       Display progress output
-    -he|lp:          Display full help
-    -usa|ge:         Display usage
-    -[no]e|xec:      Whether or not to update JIRA. -noexec says only 
-                     tell me what you would have updated.
-    -use|rname:      Username to log into JIRA with (Default: jira-admin)
-    -p|assword:      Password to log into JIRA with (Default: jira-admin's 
-                     password)
-    -bugzillaserver: Machine where Bugzilla lives (Default: bugs-dev)
-    -jiraserver:     Machine where Jira lives (Default: jira-dev)
-    -bugi|ds:        Comma separated list of BugIDs to process
-    -f|ile:          File of BugIDs, one per line
-
-=head1 DESCRIPTION
-
-This will import the comments from Bugzilla and update the corresponding JIRA
-Issues.
-
-=cut
-
-use FindBin;
-use lib "$FindBin::Bin/lib";
-
-$| = 1;
-
-use DBI;
-use Display;
-use Logger;
-use TimeUtils;
-use Utils;
-use JIRAUtils;
-use BugzillaUtils;
-
-use Getopt::Long; 
-use Pod::Usage;
-
-our %opts = (
-  exec           => 0,
-  bugzillaserver => $ENV{BUGZILLASERVER} || 'bugs-dev',
-  jiraserver     => $ENV{JIRASERVER}     || 'jira-dev:8081',
-  username       => $ENV{USERNAME},
-  password       => $ENV{PASSWORD},
-  usage          => sub { pod2usage },
-  help           => sub { pod2usage (-verbose => 2)},
-  verbose        => sub { set_verbose },
-  quiet          => 0,
-);
-
-our ($log, %total);
-
-sub sanitize ($) {
-  my ($str) = @_;
-  
-  my $p4web    = 'http://p4web.audience.local:8080/@md=d&cd=//&c=vLW@/';
-  my $bugzilla = 'http://bugs.audience.com/show_bug.cgi?id=';
-
-  # 0x93 (147) and 0x94 (148) are "smart" quotes
-  $str =~ s/[\x93\x94]/"/gm;
-  # 0x91 (145) and 0x92 (146) are "smart" singlequotes
-  $str =~ s/[\x91\x92]/'/gm;
-  # 0x96 (150) and 0x97 (151) are emdashes
-  $str =~ s/[\x96\x97]/--/gm;
-  # 0x85 (133) is an ellipsis
-  $str =~ s/\x85/.../gm;
-  # 0x95 &bull; replacement for unordered list
-  $str =~ s/\x95/*/gm;
-
-  # Make P4Web links for "CL (\d{3,6}+)"
-  $str =~ s/CL\s*(\d{3,6}+)/CL \[$1|${p4web}$1\?ac=10\]/igm;
-
-  # Make Bugzilla links for "Bug ID (\d{1,5}+)"
-  $str =~ s/Bug\s*ID\s*(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
-
-  # Make Bugzilla links for "Bug # (\d{1,5}+)"
-  $str =~ s/Bug\s*#\s*(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
-
-  # Make Bugzilla links for "Bug (\d{1,5}+)"
-  $str =~ s/Bug\s*(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
-
-  # Convert bug URLs to be more proper
-  $str =~ s/https\:\/\/bugs\.audience\.com\/show_bug\.cgi\?id=(\d{1,5}+)/Bug \[$1|${bugzilla}$1\]/igm;
-
-  return $str;
-} # sanitize
-
-sub addComments ($$) {
-  my ($jiraIssue, $bugid) = @_;
-  
-  my @comments = @{getBugComments ($bugid)};
-  
-  # Note: In Bugzilla the first comment is considered the description.
-  my $description = shift @comments;
-  
-  my $result = addDescription $jiraIssue, sanitize $description;
-  
-  $total{'Descriptions added'}++;
-  
-  return $result if $result =~ /^Unable to add comment/;
-   
-  # Process the remaining comments  
-  for (@comments) {
-    $result = addJIRAComment $jiraIssue, sanitize $_;
-    
-    if ($result =~ /Comment added/) {
-      $total{'Comments imported'}++;
-    } else {
-      return $result;
-    } # if
-  } # for
-  
-  $result = '' unless $result;
-  
-  return $result;
-} # addComments
-
-sub main () {
-  my $startTime = time;
-  
-  GetOptions (
-    \%opts,
-    'verbose',
-    'usage',
-    'help',
-    'exec!',
-    'quiet',
-    'username=s',
-    'password=s',
-    'bugids=s@',
-    'file=s',
-    'jiraserver=s',
-    'bugzillaserver=s',
-    'linkbugzilla',
-    'relinkbugzilla'
-  ) or pod2usage;
-  
-  $log = Logger->new;
-
-  if ($opts{file}) {
-    open my $file, '<', $opts{file} 
-      or $log->err ("Unable to open $opts{file} - $!", 1);
-      
-    $opts{bugids} = [<$file>];
-    
-    chomp @{$opts{bugids}};
-  } else {
-    my @bugids;
-    
-    push @bugids, (split /,/, join (',', $_)) for (@{$opts{bugids}}); 
-  
-    $opts{bugids} = [@bugids];
-  } # if
-  
-  pod2usage 'Must specify -bugids <bugid>[,<bugid>,...] or -file <filename>'
-    unless $opts{bugids};
-  
-  openBugzilla $opts{bugzillaserver}
-    or $log->err ("Unable to connect to $opts{bugzillaserver}", 1);
-  
-  Connect2JIRA ($opts{username}, $opts{password}, $opts{jiraserver})
-    or $log->err ("Unable to connect to $opts{jiraserver}", 1);
-
-  $log->msg ("Processing comments");
-
-  for (@{$opts{bugids}}) {
-    my $jiraIssue = findIssue $_;
-    
-    if ($jiraIssue =~ /^[A-Z]{1,5}-\d+$/) {
-      my $result = addComments $jiraIssue, $_;
-      
-      if ($result =~ /^Unable/) {
-        $total{'Comment failures'}++;
-
-        $log->err ("Unable to add comments for $jiraIssue ($_)\n$result");
-      } elsif ($result =~ /^Comment added/) {
-        $log->msg ("Added comments for $jiraIssue ($_)");
-      } elsif ($result =~ /^Would have linked/) {
-        $total{'Comments would be added'}++;
-      } # if
-    } else {
-      $total{'Missing JIRA Issues'}++;
-      
-      $log->err ("Unable to find JIRA Issue for Bug $_");
-    } # if
-  } # for
-
-  display_duration $startTime, $log;
-  
-  Stats (\%total, $log) unless $opts{quiet};
-
-  return 0;
-} # main
-
-exit main;
diff --git a/audience/JIRA/jiradep.pl b/audience/JIRA/jiradep.pl
deleted file mode 100644 (file)
index a8ce4c4..0000000
+++ /dev/null
@@ -1,372 +0,0 @@
-#!/usr/bin/perl
-use strict;
-use warnings;
-
-=pod
-
-=head1 NAME jiradep.pl
-
-Update Bugzilla dependencies (Dependencies/Blockers/Duplicates and Related),
-transfering those relationships over to any matching JIRA issues. 
-
-=head1 VERSION
-
-=over
-
-=item Author
-
-Andrew DeFaria <Andrew@ClearSCM.com>
-
-=item Revision
-
-$Revision: #1 $
-
-=item Created
-
-Thu Mar 20 10:11:53 PDT 2014
-
-=item Modified
-
-$Date: 2014/05/23 $
-
-=back
-
-=head1 SYNOPSIS
-
-  $ jiradep.pl [-bugzillaserver <bugshost>] [-login <login email>]
-               [-jiraserver <server>]
-               [-username <username>] [-password <password>] 
-               [-bugids bugid,bugid,... | -file <filename>] 
-               [-[no]exec] [-linkbugzilla] [-relinkbugzilla]
-               [-verbose] [-help] [-usage]
-
-  Where:
-
-    -v|erbose:       Display progress output
-    -he|lp:          Display full help
-    -usa|ge:         Display usage
-    -[no]e|xec:      Whether or not to update Bugilla. -noexec says only 
-                     tell me what you would have updated.
-    -use|rname:      Username to log into JIRA with (Default: jira-admin)
-    -p|assword:      Password to log into JIRA with (Default: jira-admin's 
-                     password)
-    -bugzillaserver: Machine where Bugzilla lives (Default: bugs-dev)
-    -jiraserver:     Machine where Jira lives (Default: jira-dev)
-    -bugi|ds:        Comma separated list of BugIDs to process
-    -f|ile:          File of BugIDs, one per line
-    -linkbugzilla:   If specified and we find that we cannot translate
-                     a Bugzilla Bud ID to a JIRA Issue then create a 
-                     remote link for the Bugzilla Bug. (Default:
-                     do not create Bugzilla remote links).
-    -relinkbugzilla: Scan current Remote Bugzilla links and if there
-                     exists a corresponding JIRA issue, remove the 
-                     Remote Bugzilla link and make it a JIRA Issue
-                     link.
-    -jiradbhost:     Host name of the machine where the MySQL jiradb
-                     database is located (Default: cm-db-ldev01)
-
-=head1 DESCRIPTION
-
-This script will process all BugIDs translating them into JIRA Issues, if
-applicable. It will then determine the relationships of this BugID in Bugzilla -
-what it blocks, what it depends on, if it's a duplicate of another bug or if
-it has any related links. Those too will be translated to JIRA issues, again,
-if applicable. Then the JIRA issue will be updates to reflect these 
-relationships. 
-
-Note that it's not known at this time what to do for situations where BugIDs
-cannot be translated into JIRA issues if such Bugzilla bugs have not yet been
-migrated to JIRA. There's a though to simply make a Bugzilla Link but we will
-need to keep that in mind and when we import the next project to JIRA these
-old, no longer used Bugzilla Links should be converted to their corresponding 
-JIRA issue. Perhaps this script can do that too.
-
-=cut
-
-use FindBin;
-use lib "$FindBin::Bin/lib";
-
-$| = 1;
-
-use DBI;
-use Display;
-use Logger;
-use TimeUtils;
-use Utils;
-use JIRAUtils;
-use BugzillaUtils;
-
-use Getopt::Long; 
-use Pod::Usage;
-
-our %opts = (
-  exec           => 0,
-  bugzillaserver => $ENV{BUGZILLASERVER} || 'bugs-dev',
-  jiraserver     => $ENV{JIRASERVER}     || 'jira-dev',
-  jiradbhost     => $ENV{JIRA_DB_HOST}   || 'cm-db-ldev01',
-  username       => 'jira-admin',
-  password       => 'jira-admin',
-  usage          => sub { pod2usage },
-  help           => sub { pod2usage (-verbose => 2)},
-  verbose        => sub { set_verbose },
-  quiet          => 0,
-  usage          => sub { pod2usage },
-  help           => sub { pod2usage (-verbose => 2)},
-);
-
-our ($log, %total);
-
-my %relationshipMap = (
-  Blocks    => 'Dependencies Linked',
-  Duplicate => 'Duplicates Linked',
-  Related   => 'Related Linked',
-);
-
-sub callLink ($$$$) {
-  my ($from, $type, $to, $counter) = @_;
-
-  my $bugzillaType;
-  
-  if ($from =~ /^\d+/) {
-    if ($type eq 'Blocks') {
-      $bugzillaType = 'is blocked by (Bugzilla)';
-    } elsif ($type eq 'Duplicate') {
-      $bugzillaType = 'duplicate (Bugzilla)';
-    } elsif ($type eq 'Related') {
-      $bugzillaType = 'related (Bugzilla)';
-    } # if
-  } elsif ($to =~ /^\d+/) {
-    if ($type eq 'Blocks') {
-      $bugzillaType = 'blocks (Bugzilla)';
-    } elsif ($type eq 'Duplicate') {
-      $bugzillaType = 'duplicate (Bugzilla)';
-    } elsif ($type eq 'Related') {
-      $bugzillaType = 'related (Bugzilla)';
-    } # if
-  } # if
-    
-  $total{$counter}++;
-
-  if ($from =~ /^\d+/ && $to =~ /^\d+/) {
-    $total{'Skipped Bugzilla Links'}++;
-    
-    return "Refusing to link because both from ($from) and to ($to) links are still a Bugzilla link";
-  } elsif ($from =~ /^\d+/) {
-    if ($opts{linkbugzilla}) {
-      my $result = addRemoteLink $from, $bugzillaType, $to;
-
-      $total{'Bugzilla Links'}++ unless $result;
-    
-      if ($result eq '') {
-        return "Created remote $type link between Issue $to and Bug $from";
-      } else {
-        return $result;
-      } # if 
-    } else {
-      $total{'Skipped Bugzilla Links'}++;
-    
-      return "Refusing to link because from link ($from) is still a Bugzilla link";
-    } # if
-  } elsif ($to =~ /^\d+/) {
-    if ($opts{linkbugzilla}) {
-      my $result = addRemoteLink $to, $bugzillaType, $from;
-    
-      $total{'Bugzilla Links'}++ unless $result;
-
-      if (!defined $result) {
-        print "huh?";
-      }
-      if ($result eq '') {
-        return "Created remote $type link between Issue $from and Bug $to";
-      } else {
-        return $result;
-      } # if 
-    } else {
-      $total{'Skipped Bugzilla Links'}++;
-    
-      return "Refusing to link because to link ($to) is still a Bugzilla link";
-    } # if
-  } # if
-   
-  my $result = linkIssues $from, $type, $to;
-  
-  $log->msg ($result);
-    
-  if ($result =~ /^Unable/) {
-    $total{'Link Failures'}++;
-  } elsif ($result =~ /^Link made/) {
-    $total{'Links made'}++;
-  } elsif ($result =~ /^Would have linked/) {
-    $total{'Links would be made'}++;
-  } # if
-      
-  return;
-} # callLink
-
-sub relinkBugzilla (@) {
-  my (@bugids) = @_;
-  
-  my %mapRelationships = (
-    'blocks (Bugzilla)'           => 'Blocks',
-    'is blocked by (Bugzilla)'    => 'Blocks',
-    'duplicates (Bugzilla)'       => 'Duplicates',
-    'is duplicated by (Bugzilla)' => 'Duplicates',
-    # old versions...
-    'Bugzilla blocks'             => 'Blocks',
-    'Bugzilla is blocked by'      => 'Blocks',
-    'Bugzilla duplicates'         => 'Duplicates',
-    'Bugzilla is duplicated by'   => 'Duplicates',
-  );
-  
-  @bugids = getRemoteLinks unless @bugids;
-  
-  for my $bugid (@bugids) {
-    $total{'Remote Links Scanned'}++;
-    
-    my $links = findRemoteLinkByBugID $bugid;
-
-    my $jirafrom = findIssue ($bugid);
-    
-    next if $jirafrom !~ /^[A-Z]{1,5}-\d+$/;
-        
-    for (@$links) {
-      my %link = %$_;
-      
-      # Found a link to JIRA. Remove remotelink and make an issuelink
-      if ($mapRelationships{$link{relationship}}) {
-        my ($fromIssue, $toIssue);
-        
-        if ($link{relationship} =~ / by/) {
-          $fromIssue = $jirafrom;
-          $toIssue   = $link{issue};
-        } else {
-          $fromIssue = $link{issue};
-          $toIssue   = $jirafrom;
-        } # if
-        
-        my $status = promoteBug2JIRAIssue $bugid, $fromIssue, $toIssue,
-                                          $mapRelationships{$link{relationship}};
-
-        $log->err ($status) if $status =~ /Unable to link/;
-      } else {
-        $log->err ("Unable to handle relationships of type $link{relationship}");
-      } # if
-    } # for
-  } # for
-    
-  return;
-} # relinkBugzilla
-
-sub main () {
-  my $startTime = time;
-  
-  GetOptions (
-    \%opts,
-    'verbose',
-    'usage',
-    'help',
-    'exec!',
-    'quiet',
-    'username=s',
-    'password=s',
-    'bugids=s@',
-    'file=s',
-    'jiraserver=s',
-    'bugzillaserver=s',
-    'linkbugzilla',
-    'relinkbugzilla',
-    'jiradbhost=s',
-  ) or pod2usage;
-  
-  $log = Logger->new;
-
-  if ($opts{file}) {
-    open my $file, '<', $opts{file} 
-      or $log->err ("Unable to open $opts{file} - $!", 1);
-      
-    $opts{bugids} = [<$file>];
-    
-    chomp @{$opts{bugids}};
-  } else {
-    my @bugids;
-    
-    push @bugids, (split /,/, join (',', $_)) for (@{$opts{bugids}}); 
-  
-    $opts{bugids} = [@bugids];
-  } # if
-  
-  pod2usage 'Must specify -bugids <bugid>[,<bugid>,...] or -file <filename>'
-    unless ($opts{bugids} > 0 or $opts{relinkbugzilla});
-  
-  openBugzilla $opts{bugzillaserver}
-    or $log->err ("Unable to connect to $opts{bugzillaserver}", 1);
-  
-  Connect2JIRA ($opts{username}, $opts{password}, $opts{jiraserver})
-    or $log->err ("Unable to connect to $opts{jiraserver}", 1);
-
-  if ($opts{relinkbugzilla}) {
-    unless (@{$opts{bugids}}) {
-      relinkBugzilla;
-    } else {
-      relinkBugzilla $_ for @{$opts{bugids}}
-    } # unless
-        
-    Stats (\%total, $log);
-    
-    exit $log->errors;
-  } # if
-  
-  my %relationships;
-  
-  # The 'Blocks' IssueLinkType has two types of relationships in it - both
-  # blocks and dependson. Since JIRA has only one type - Blocks - we take
-  # the $dependson and flip the from and to.
-  my $blocks    = getBlockers @{$opts{bugids}};
-  my $dependson = getDependencies @{$opts{bugids}};
-  
-  # Now merge them - we did it backwards!
-  for my $fromLink (keys %$dependson) {
-    for my $toLink (@{$dependson->{$fromLink}}) {
-      push @{$relationships{Blocks}{$toLink}}, $fromLink;
-    } # for
-  } # for
-
-  #%{$relationships{Blocks}} = %$dependson;
-  
-  for my $fromLink (keys %$blocks) {
-    # Check to see if we already have the reverse of this link
-    for my $toLink (@{$blocks->{$fromLink}}) {
-      unless (grep {$toLink eq $_} keys %{$relationships{Blocks}}) {
-        push @{$relationships{Blocks}{$fromLink}}, $toLink;
-      } # unless
-    } # for
-  } # for
-  
-  $relationships{Duplicate} = getDuplicates   @{$opts{bugids}};
-  $relationships{Relates}   = getRelated      @{$opts{bugids}};
-  
-  # Process relationships (social programming... ;-)
-  $log->msg ("Processing relationships");
-  
-  for my $type (keys %relationshipMap) {
-    for my $from (keys %{$relationships{$type}}) {
-      for my $to (@{$relationships{$type}{$from}}) {
-        $total{'Relationships processed'}++;
-        
-        my $result = callLink $from, $type, $to, $relationshipMap{$type};
-        
-        $log->msg ($result) if $result;
-      } # for
-    } # for
-  } # if
-
-  display_duration $startTime, $log;
-  
-  Stats (\%total, $log) unless $opts{quiet};
-
-  return;
-} # main
-
-main;
-
-exit;
\ No newline at end of file
diff --git a/audience/JIRA/lib/BugzillaUtils.pm b/audience/JIRA/lib/BugzillaUtils.pm
deleted file mode 100644 (file)
index 899f506..0000000
+++ /dev/null
@@ -1,322 +0,0 @@
-=pod
-
-=head1 NAME $RCSfile: BugzillaUtils.pm,v $
-
-Some shared functions dealing with Bugzilla. Note this uses DBI to directly
-access Bugzilla's database. This requires that your userid was granted access.
-For this I setup adefaria with pretty much read only access.
-
-=head1 VERSION
-
-=over
-
-=item Author
-
-Andrew DeFaria <Andrew@ClearSCM.com>
-
-=item Revision
-
-$Revision: 1.0 $
-
-=item Created
-
-Fri Mar 12 10:17:44 PST 2004
-
-=item Modified
-
-$Date: 2013/05/30 15:48:06 $
-
-=head1 ROUTINES
-
-The following routines are exported:
-
-=cut
-
-package BugzillaUtils;
-
-use strict;
-use warnings;
-
-use base 'Exporter';
-
-use FindBin;
-use Display;
-use Carp;
-use DBI;
-
-use lib 'lib';
-
-use JIRAUtils;
-
-our $bugzilla;
-
-our @EXPORT = qw (
-  openBugzilla
-  getRelationships
-  getDependencies
-  getBlockers
-  getDuplicates
-  getRelated
-  getBug
-  getBugComments
-  getWatchers
-);
-
-sub _checkDBError ($$) {
-  my ($msg, $statement) = @_;
-
-  my $dberr    = $bugzilla->err;
-  my $dberrmsg = $bugzilla->errstr;
-
-  $dberr    ||= 0;
-  $dberrmsg ||= 'Success';
-
-  my $message = '';
-
-  if ($dberr) {
-    my $function = (caller (1)) [3];
-
-    $message = "$function: $msg\nError #$dberr: $dberrmsg\n"
-             . "SQL Statement: $statement";
-  } # if
-
-  $main::log->err ($message, $dberr) if $dberr;
-
-  return;
-} # _checkDBError
-
-sub openBugzilla (;$$$$) {
-  my ($dbhost, $dbname, $dbuser, $dbpass) = @_;
-
-  $dbhost //= 'jira-dev';
-  $dbname //= 'bugzilla';
-  $dbuser //= 'adefaria';
-  $dbpass //= 'reader';
-  
-  $main::log->msg ("Connecting to Bugzilla ($dbuser\@$dbhost)");
-  
-  $bugzilla = DBI->connect (
-    "DBI:mysql:$dbname:$dbhost",
-    $dbuser,
-    $dbpass, {
-      PrintError => 0,
-      RaiseError => 1,
-    },
-  );
-  
-  _checkDBError 'Unable to execute statement', 'Connect';
-
-  return $bugzilla;
-} # openBugzilla
-
-sub getBug ($;@) {
-  my ($bugid, @fields) = @_;
-  
-  push @fields, 'short_desc' unless @fields;
-  
-  my $statement = 'select ' . join (',', @fields) .
-                  " from bugs where bug_id = $bugid";
-                  
-  my $sth = $bugzilla->prepare ($statement);
-
-  _checkDBError 'Unable to prepare statement', $statement;
-  
-  _checkDBError 'Unable to execute statement', $statement;
-
-  $sth->execute;
-
-  return $sth->fetchrow_hashref;
-} # getBug
-
-sub getBugComments ($) {
-  my ($bugid) = @_;
-  
-  my $statement = <<"END";
-select 
-  bug_id, 
-  bug_when, 
-  substring_index(login_name,'\@',1) as username,
-  thetext
-from
-  longdescs,
-  profiles
-where
-  who    = userid and
-  bug_id = $bugid 
-END
-  
-  my $sth = $bugzilla->prepare ($statement);
-  
-  _checkDBError 'Unable to prepare statement', $statement;
-
-  $sth->execute;
-  
-  _checkDBError 'Unable to execute statement', $statement;
-  
-  my @comments;
-  
-  while (my $comment = $sth->fetchrow_hashref) {
-    my $commentText = <<"END";
-The following comment was entered by [~$comment->{username}] on $comment->{bug_when}:
-
-$comment->{thetext}
-END
-
-    push @comments, $commentText;
-  } # while
-  
-  return \@comments;
-} # getBugComments
-
-sub getRelationships ($$$$@) {
-  my ($table, $returnField, $testField, $relationshipType, @bugs) = @_;
-  
-  $main::log->msg ("Getting $relationshipType");
-  
-  my $statement = "select $returnField from $table where $table.$testField = ?";
-
-  my $sth = $bugzilla->prepare ($statement);
-
-  _checkDBError 'Unable to prepare statement', $statement;
-  
-  my %relationships;
-
-  my %bugmap;
-  
-  map {$bugmap{$_} = 1} @bugs unless %bugmap;
-      
-  for my $bugid (@bugs) {
-    $sth->execute ($bugid);
-    
-    _checkDBError 'Unable to exit statement', $statement;
-    
-    my $result = JIRAUtils::findIssue ($bugid, %bugmap);
-    
-    if ($result =~ /^Unable/) {
-      $main::log->warn ($result);
-      
-      $main::total{'Missing JIRA Issues'}++;
-      
-      undef $result;
-    } elsif ($result =~ /^Future/) {
-      $main::total{'Future JIRA Issues'}++;
-      
-      undef $result;
-    } # if
-    
-    my $jiraIssue = $result;
-    my $key       = $jiraIssue || $bugid;
-
-    my @relationships;
-    my $relations = $sth->fetchall_arrayref;
-    my @relations;
-    
-    map {push @relations, $_->[0]} @$relations;
-    
-    for my $relation (@relations) {
-      $jiraIssue = JIRAUtils::findIssue ($relation);
-      
-      if ($jiraIssue =~ /^Unable/ || $jiraIssue =~ /^Future/) {
-        $main::log->warn ($jiraIssue);
-
-        $main::total{'Missing JIRA Issues'}++ if $jiraIssue =~ /^Unable/;
-        $main::total{'Future JIRA Issues'}++  if $jiraIssue =~ /^Future/;
-        
-        push @relationships, $relation;
-      } else {
-        push @relationships, $jiraIssue;
-      } # if
-    } # for
-      
-    push @{$relationships{$key}}, @relationships if @relationships;
-  } # for
-  
-  $main::total{$relationshipType} = keys %relationships;
-  
-  return \%relationships;
-} # getRelationships
-
-sub getDependencies (@) {
-  my (@bugs) = @_;
-
-  return getRelationships (
-    'dependencies', # table 
-    'dependson',    # returned field
-    'blocked',      # test field
-    'Depends on',   # relationship
-    @bugs
-  );
-} # getDependencies
-
-sub getBlockers (@) {
-  my (@bugs) = @_;
-  
-  return getRelationships (
-    'dependencies', 
-    'blocked', 
-    'dependson', 
-    'Blocks',
-    @bugs
-  );
-} # getBlockers
-
-sub getDuplicates (@) {
-  my (@bugs) = @_;
-  
-  return getRelationships (
-    'duplicates', 
-    'dupe', 
-    'dupe_of', 
-    'Duplicates',
-    @bugs
-  );
-} # getDuplicates
-
-sub getRelated (@) {
-  my (@bugs) = @_;
-  
-  return getRelationships (
-    'bug_see_also', 
-    'value', 
-    'bug_id', 
-    'Relates',
-    @bugs
-  );
-} # getRelated
-
-sub getWatchers ($) {
-  my ($bugid) = @_;
-  
-  my $statement = <<"END";
-select 
-  profiles.login_name
-from 
-  cc,
-  profiles
-where 
-  cc.who = profiles.userid and
-  bug_id = ?
-END
-
-  my $sth = $bugzilla->prepare ($statement);
-
-  _checkDBError 'Unable to prepare statement', $statement;
-
-  $sth->execute ($bugid);
-
-  _checkDBError 'Unable to execute statement', $statement;
-
-  my @rows = @{$sth->fetchall_arrayref};
-
-  my %watchers;
-
-  for (@rows) {
-    if ($$_[0] =~ /(.*)\@/) {
-      $watchers{$1} = 1;
-    } # if
-
-    $main::total{'Watchers Processed'}++;  
-  } # for
-
-  return %watchers;
-} # getWatchers
\ No newline at end of file
diff --git a/audience/JIRA/lib/JIRAUtils.pm b/audience/JIRA/lib/JIRAUtils.pm
deleted file mode 100644 (file)
index a60e01c..0000000
+++ /dev/null
@@ -1,655 +0,0 @@
-=pod
-
-=head1 NAME $RCSfile: JIRAUtils.pm,v $
-
-Some shared functions dealing with JIRA
-
-=head1 VERSION
-
-=over
-
-=item Author
-
-Andrew DeFaria <Andrew@ClearSCM.com>
-
-=item Revision
-
-$Revision: 1.0 $
-
-=item Created
-
-Fri Mar 12 10:17:44 PST 2004
-
-=item Modified
-
-$Date: 2013/05/30 15:48:06 $
-
-=back
-
-=head1 ROUTINES
-
-The following routines are exported:
-
-=cut
-
-package JIRAUtils;
-
-use strict;
-use warnings;
-
-use base 'Exporter';
-
-use FindBin;
-use Display;
-use Carp;
-use DBI;
-
-use JIRA::REST;
-use BugzillaUtils;
-
-our ($jira, %opts);
-
-our @EXPORT = qw (
-  addJIRAComment
-  addDescription
-  Connect2JIRA
-  findIssue
-  getIssue
-  getIssueLinks
-  getIssueLinkTypes
-  getRemoteLinks
-  updateIssueWatchers
-  linkIssues
-  addRemoteLink
-  getRemoteLink
-  removeRemoteLink
-  getRemoteLinkByBugID
-  promoteBug2JIRAIssue
-  findRemoteLinkByBugID
-);
-
-my (@issueLinkTypes, %total, %cache, $jiradb);
-
-sub _checkDBError ($;$) {
-  my ($msg, $statement) = @_;
-
-  $statement //= 'Unknown';
-  
-  $main::log->err ('JIRA database not opened!', 1) unless $jiradb;
-  
-  my $dberr    = $jiradb->err;
-  my $dberrmsg = $jiradb->errstr;
-  
-  $dberr    ||= 0;
-  $dberrmsg ||= 'Success';
-
-  my $message = '';
-  
-  if ($dberr) {
-    my $function = (caller (1)) [3];
-
-    $message = "$function: $msg\nError #$dberr: $dberrmsg\n"
-             . "SQL Statement: $statement";
-  } # if
-
-  $main::log->err ($message, 1) if $dberr;
-
-  return;
-} # _checkDBError
-
-sub openJIRADB (;$$$$) {
-  my ($dbhost, $dbname, $dbuser, $dbpass) = @_;
-
-  $dbhost //= $main::opts{jiradbhost};
-  $dbname //= 'jiradb';
-  $dbuser //= 'adefaria';
-  $dbpass //= 'reader';
-  
-  $main::log->msg ("Connecting to JIRA ($dbuser\@$dbhost)...");
-  
-  $jiradb = DBI->connect (
-    "DBI:mysql:$dbname:$dbhost",
-    $dbuser,
-    $dbpass, {
-      PrintError => 0,
-      RaiseError => 1,
-    },
-  );
-
-  _checkDBError "Unable to open $dbname ($dbuser\@$dbhost)";
-  
-  return $jiradb;
-} # openJIRADB
-
-sub Connect2JIRA (;$$$) {
-  my ($username, $password, $server) = @_;
-
-  my %opts;
-  
-  $opts{username} = $username || 'jira-admin';
-  $opts{password} = $password || $ENV{PASSWORD}    || 'jira-admin';
-  $opts{server}   = $server   || $ENV{JIRA_SERVER} || 'jira-dev:8081';
-  $opts{URL}      = "http://$opts{server}/rest/api/latest";
-  
-  $main::log->msg ("Connecting to JIRA ($opts{username}\@$opts{server})");
-  
-  $jira = JIRA::REST->new ($opts{URL}, $opts{username}, $opts{password});
-
-  # Store username as we might need it (see updateIssueWatchers)
-  $jira->{username} = $opts{username};
-  
-  return $jira;  
-} # Connect2JIRA
-
-sub addDescription ($$) {
-  my ($issue, $description) = @_;
-  
-  if ($main::opts{exec}) {
-    eval {$jira->PUT ("/issue/$issue", undef, {fields => {description => $description}})};
-  
-    if ($@) {
-      return "Unable to add description\n$@";
-    } else {
-      return 'Description added';
-    } # if
-  } # if
-} # addDescription
-
-sub addJIRAComment ($$) {
-  my ($issue, $comment) = @_;
-  
-  if ($main::opts{exec}) {
-    eval {$jira->POST ("/issue/$issue/comment", undef, { body => $comment })};
-  
-    if ($@) {
-      return "Unable to add comment\n$@";
-    } else {
-      return 'Comment added';
-    } # if
-  } else {
-    return "Would have added comments to $issue";
-  } # if
-} # addJIRAComment
-
-sub findIssue ($%) {
-  my ($bugid, %bugmap) = @_;
-  
-=pod
-  # Check the cache...
-  if ($cache{$bugid}) {
-    if ($cache{$bugid} =~ /^\d+/) {
-      # We have a cache hit but the contents here are a bugid. This means we had
-      # searched for the corresponding JIRA issue for this bug before and came
-      # up empty handed. In this situtaion we really have:
-      return "Unable to find a JIRA issue for Bug $bugid"; 
-    } else {
-      return $cache{$bugid};
-    } # if
-  } # if
-=cut  
-  my $issue;
-
-  my %query = (
-    jql    => "\"Bugzilla Bug Number\" ~ $bugid",
-    fields => [ 'key' ],
-  );
-  
-  eval {$issue = $jira->GET ("/search/", \%query)};
-
-  my $issueID = $issue->{issues}[0]{key};
-  
-  if (@{$issue->{issues}} > 2) {
-    $main::log->err ("Found more than 2 issues for Bug ID $bugid");
-    
-    return "Found more than 2 issues for Bug ID $bugid";
-  } elsif (@{$issue->{issues}} == 2) {
-    my ($issueNum0, $issueNum1);
-    
-    if ($issue->{issues}[0]{key} =~ /(\d+)/) {
-      $issueNum0 = $1;
-    } # if
-    
-    if ($issue->{issues}[1]{key} =~ /(\d+)/) {
-      $issueNum1 = $1;
-    } # if
-    
-    if ($issueNum0 < $issueNum1) {
-      $issueID = $issue->{issues}[1]{key};
-    } # if
-    
-    # Let's mark them as clones. See if this clone link already exists...
-    my $alreadyCloned;
-    
-    for (getIssueLinks ($issueID, 'Cloners')) {
-      my $inwardIssue  = $_->{inwardIssue}{key}  || '';
-      my $outwardIssue = $_->{outwardIssue}{key} || '';
-      
-      if ("RDBNK-$issueNum0" eq $inwardIssue  ||
-          "RDBNK-$issueNum0" eq $outwardIssue ||
-          "RDBNK-$issueNum1" eq $inwardIssue  ||
-          "RDBNK-$issueNum1" eq $outwardIssue) {
-         $alreadyCloned = 1;
-         
-         last;
-      } # if
-    } # for
-
-    unless ($alreadyCloned) {
-      my $result = linkIssues ("RDBNK-$issueNum0", 'Cloners', "RDBNK-$issueNum1");
-    
-      return $result if $result =~ /Unable to/;
-    
-      $main::log->msg ($result);
-    } # unless
-  } # if
-
-  if ($issueID) {
-    $main::log->msg ("Found JIRA issue $issueID for Bug $bugid");
-  
-    #$cache{$bugid} = $issueID;
-      
-    #return $cache{$bugid};
-    return $issueID;
-  } else {
-    my $status = $bugmap{$bugid} ? 'Future JIRA Issue'
-                                 : "Unable to find a JIRA issue for Bug $bugid";
-    
-    # Here we put this bugid into the cache but instead of a the JIRA issue
-    # id we put the bugid. This will stop us from adding up multiple hits on
-    # this bugid.
-    #$cache{$bugid} = $bugid;
-
-    return $status;
-  } # if
-} # findJIRA
-
-sub getIssue ($;@) {
-  my ($issue, @fields) = @_;
-  
-  my $fields = @fields ? "?fields=" . join ',', @fields : '';
-
-  return $jira->GET ("/issue/$issue$fields");
-} # getIssue
-
-sub getIssueLinkTypes () {
-  my $issueLinkTypes = $jira->GET ('/issueLinkType/');
-  
-  map {push @issueLinkTypes, $_->{name}} @{$issueLinkTypes->{issueLinkTypes}};
-  
-  return @issueLinkTypes
-} # getIssueLinkTypes
-
-sub linkIssues ($$$) {
-  my ($from, $type, $to) = @_;
-  
-  unless (@issueLinkTypes) {
-    getIssueLinkTypes;
-  } # unless
-  
-  unless (grep {$type eq $_} @issueLinkTypes) {
-    $main::log->err ("Type $type is not a valid issue link type\nValid types include:\n" 
-               . join "\n\t", @issueLinkTypes);
-               
-    return "Unable to $type link $from -> $to";           
-  } # unless  
-  
-  my %link = (
-    inwardIssue  => {
-      key        => $from,
-    },
-    type         => {
-      name       => $type,
-    },
-    outwardIssue => {
-      key        => $to,
-    },
-    comment      => {
-      body       => "Link ported as part of the migration from Bugzilla: $from <-> $to",
-    },
-  );
-  
-  $main::total{'IssueLinks Added'}++;
-  
-  if ($main::opts{exec}) {
-    eval {$jira->POST ("/issueLink", undef, \%link)};
-    
-    if ($@) {
-      return "Unable to $type link $from -> $to\n$@";
-    } else {
-      return "Made $type link $from -> $to";
-    } # if
-  } else {
-    return "Would have $type linked $from -> $to";
-  } # if
-} # linkIssue
-
-sub getRemoteLink ($;$) {
-  my ($jiraIssue, $id) = @_;
-  
-  $id //= '';
-  
-  my $result;
-  
-  eval {$result = $jira->GET ("/issue/$jiraIssue/remotelink/$id")};
-  
-  return if $@;
-  
-  my %remoteLinks;
-
-  if (ref $result eq 'ARRAY') {
-    map {$remoteLinks{$_->{id}} = $_->{object}{title}} @$result;  
-  } else {
-    $remoteLinks{$result->{id}} = $result->{object}{title};
-  } # if
-    
-  return \%remoteLinks;
-} # getRemoteLink
-
-sub getRemoteLinks (;$) {
-  my ($bugid) = @_;
-  
-  $jiradb = openJIRADB unless $jiradb;
-  
-  my $statement = 'select url from remotelink';
-
-  $statement .= " where url like 'http://bugs%'";  
-  $statement .= " and url like '%$bugid'" if $bugid; 
-  $statement .= " group by issueid desc";
-  
-  my $sth = $jiradb->prepare ($statement);
-  
-  _checkDBError 'Unable to prepare statement', $statement;
-  
-  $sth->execute;
-  
-  _checkDBError 'Unable to execute statement', $statement;
-
-  my %bugids;
-  
-  while (my $record = $sth->fetchrow_array) {
-    if ($record =~ /(\d+)$/) {
-      $bugids{$1} = 1;
-    } # if 
-  } # while
-  
-  return keys %bugids;
-} # getRemoteLinks
-
-sub findRemoteLinkByBugID (;$) {
-  my ($bugid) = @_;
-  
-  my $condition = 'where issueid = jiraissue.id and jiraissue.project = project.id';
-  
-  if ($bugid) {
-    $condition .= " and remotelink.url like '%id=$bugid'";
-  } # unless
-  
-  $jiradb = openJIRADB unless $jiradb;
-
-  my $statement = <<"END";
-select 
-  remotelink.id, 
-  concat (project.pkey, '-', issuenum) as issue,
-  relationship
-from
-  remotelink,
-  jiraissue,
-  project
-$condition
-END
-
-  my $sth = $jiradb->prepare ($statement);
-  
-  _checkDBError 'Unable to prepare statement', $statement;
-  
-  $sth->execute;
-  
-  _checkDBError 'Unable to execute statement', $statement;
-  
-  my @records;
-  
-  while (my $row = $sth->fetchrow_hashref) {
-    $row->{bugid} = $bugid;
-        
-    push @records, $row;
-  } # while
-  
-  return \@records;
-} # findRemoteLinkByBugID
-
-sub promoteBug2JIRAIssue ($$$$) {
-  my ($bugid, $jirafrom, $jirato, $relationship) = @_;
-
-  my $result = linkIssues $jirafrom, $relationship, $jirato;
-        
-  return $result if $result =~ /Unable to link/;
-  
-  $main::log->msg ($result . " (BugID $bugid)");
-  
-  for (@{findRemoteLinkByBugID $bugid}) {
-    my %record = %$_;
-    
-    $result = removeRemoteLink ($record{issue}, $record{id});
-    
-    # We may not care if we couldn't remove this link because it may have been
-    # removed by a prior pass.
-    return $result if $result =~ /Unable to remove link/;
-    
-    $main::log->msg ($result) unless $result eq '';
-  } # for
-  
-  return $result;
-} # promoteBug2JIRAIssue
-
-sub addRemoteLink ($$$) {
-  my ($bugid, $relationship, $jiraIssue) = @_;
-  
-  my $bug = getBug $bugid;
-  
-  # Check to see if this Bug ID already exists on this JIRA Issue, otherwise
-  # JIRA will duplicate it! 
-  my $remoteLinks = getRemoteLink $jiraIssue;
-  
-  for (keys %$remoteLinks) {
-    if ($remoteLinks->{$_} =~ /Bug (\d+)/) {
-      return "Bug $bugid is already linked to $jiraIssue" if $bugid == $1;
-    } # if
-  } # for
-  
-  # Note this globalid thing is NOT working! ALl I see is null in the database
-  my %remoteLink = (
-#    globalid     => "system=http://bugs.audience.com/show_bug.cgi?id=$bugid",
-#    application  => {
-#      type       => 'Bugzilla',
-#      name       => 'Bugzilla',
-#    },
-    relationship => $relationship, 
-    object       => {
-      url        => "http://bugs.audience.com/show_bug.cgi?id=$bugid",
-      title      => "Bug $bugid",
-      summary    => $bug->{short_desc},
-      icon       => {
-        url16x16 => 'http://bugs.audience.local/favicon.png',
-        title    => 'Bugzilla Bug',
-      },
-    },
-  );
-  
-  $main::total{'RemoteLink Added'}++;
-  
-  if ($main::opts{exec}) {
-    eval {$jira->POST ("/issue/$jiraIssue/remotelink", undef, \%remoteLink)};
-  
-    return $@;
-  } else {
-    return "Would have linked $bugid -> $jiraIssue";
-  } # if
-} # addRemoteLink
-
-sub removeRemoteLink ($;$) {
-  my ($jiraIssue, $id) = @_;
-  
-  $id //= '';
-  
-  my $remoteLinks = getRemoteLink ($jiraIssue, $id);
-  
-  for (keys %$remoteLinks) {
-    my $result;
-    
-    $main::total{'RemoteLink Removed'}++;
-  
-    if ($main::opts{exec}) {
-      eval {$result = $jira->DELETE ("/issue/$jiraIssue/remotelink/$_")};
-
-      if ($@) {  
-        return "Unable to remove remotelink $jiraIssue ($id)\n$@" if $@;
-      } else {
-        my $bugid;
-        
-        if ($remoteLinks->{$_} =~ /(\d+)/) {
-          return "Removed remote link $jiraIssue (Bug ID $1)";
-        } # if
-      } # if
-      
-      $main::total{'Remote Links Removed'}++;
-    } else {
-      if ($remoteLinks->{$_} =~ /(\d+)/) {
-        return "Would have removed remote link $jiraIssue (Bug ID $1)";
-      } # if
-    } # if
-  } # for  
-} # removeRemoteLink
-
-sub getIssueLinks ($;$) {
-  my ($issue, $type) = @_;
-  
-  my @links = getIssue ($issue, ('issuelinks'));
-  
-  my @issueLinks;
-
-  for (@{$links[0]->{fields}{issuelinks}}) {
-     my %issueLink = %$_;
-     
-     next if ($type && $type ne $issueLink{type}{name});
-     
-     push @issueLinks, \%issueLink;  
-  }
-  
-  return @issueLinks;
-} # getIssueLinks
-
-sub updateIssueWatchers ($%) {
-  my ($issue, %watchers) = @_;
-
-  my $existingWatchers;
-  
-  eval {$existingWatchers = $jira->GET ("/issue/$issue/watchers")};
-  
-  return "Unable to get issue $issue\n$@" if $@;
-  
-  for (@{$existingWatchers->{watchers}}) {
-    # Cleanup: Remove the current user from the watchers list.
-    # If he's on the list then remove him.
-    if ($_->{name} eq $jira->{username}) {
-      $jira->DELETE ("/issue/$issue/watchers?username=$_->{name}");
-      
-      $total{"Admins destroyed"}++;
-    } # if
-    
-    # Delete any matching watchers
-    delete $watchers{lc ($_->{name})} if $watchers{lc ($_->{name})};
-  } # for
-
-  return '' if keys %watchers == 0;
-  
-  my $issueUpdated;
-  
-  for (keys %watchers) {
-    if ($main::opts{exec}) {
-      eval {$jira->POST ("/issue/$issue/watchers", undef, $_)};
-    
-      if ($@) {
-        $main::log->warn ("Unable to add user $_ as a watcher to JIRA Issue $issue");
-      
-        $main::total{'Watchers skipped'}++;
-      } else {
-        $issueUpdated = 1;
-        
-        $main::total{'Watchers added'}++;
-      } # if
-    } else {
-      $main::log->msg ("Would have added user $_ as a watcher to JIRA Issue $issue");
-      
-      $main::total{'Watchers that would have been added'}++;
-    } # if
-  } # for
-  
-  $main::total{'Issues updated'}++ if $issueUpdated;
-  
-  return '';
-} # updateIssueWatchers
-
-=pod
-
-I'm pretty sure I'm not using this routine anymore and I don't think it works.
-If you wish to reserect this then please test.
-
-sub updateWatchers ($%) {
-  my ($issue, %watchers) = @_;
-
-  my $existingWatchers;
-  
-  eval {$existingWatchers = $jira->GET ("/issue/$issue/watchers")};
-  
-  if ($@) {
-    error "Unable to get issue $issue";
-    
-    $main::total{'Missing JIRA Issues'}++;
-    
-    return;
-  } # if
-  
-  for (@{$existingWatchers->{watchers}}) {
-    # Cleanup: Mike Admin Cogan was added as a watcher for each issue imported.
-    # If he's on the list then remove him.
-    if ($_->{name} eq 'mcoganAdmin') {
-      $jira->DELETE ("/issue/$issue/watchers?username=$_->{name}");
-      
-      $main::total{"mcoganAdmin's destroyed"}++;
-    } # if
-    
-    # Delete any matching watchers
-    delete $watchers{$_->{name}} if $watchers{$_->{name}};
-  } # for
-
-  return if keys %watchers == 0;
-  
-  my $issueUpdated;
-  
-  for (keys %watchers) {
-    if ($main::opts{exec}) {
-      eval {$jira->POST ("/issue/$issue/watchers", undef, $_)};
-    
-      if ($@) {
-        error "Unable to add user $_ as a watcher to JIRA Issue $issue";
-      
-        $main::total{'Watchers skipped'}++;
-      } else {
-        $main::total{'Watchers added'}++;
-        
-        $issueUpdated = 1;
-      } # if
-    } else {
-      $main::log->msg ("Would have added user $_ as a watcher to JIRA Issue $issue");
-      
-      $main::total{'Watchers that would have been added'}++;
-    } # if
-  } # for
-  
-  $main::total{'Issues updated'}++ if $issueUpdated;
-  
-  return;
-} # updateWatchers
-=cut
-
-1;
diff --git a/audience/JIRA/updateWatchLists.pl b/audience/JIRA/updateWatchLists.pl
deleted file mode 100644 (file)
index 5a5c4a0..0000000
+++ /dev/null
@@ -1,170 +0,0 @@
-#!/usr/bin/perl
-use strict;
-use warnings;
-
-=pod
-
-=head1 NAME updateWatchLists.pl
-
-Copy CC lists from Bugzilla -> JIRA
-
-=head1 VERSION
-
-=over
-
-=item Author
-
-Andrew DeFaria <Andrew@ClearSCM.com>
-
-=item Revision
-
-$Revision: #1 $
-
-=item Created
-
-Thu Mar 20 10:11:53 PDT 2014
-
-=item Modified
-
-$Date: 2014/05/23 $
-
-=back
-
-=head1 SYNOPSIS
-
- Updates JIRA watchlists by copying the CC list information from Bugzilla
-  $ updateWatchLists.pl [-login <login email>] [-products product1,
-                        product2,...] [-[no]exec]
-                        [-verbose] [-help] [-usage]
-
-  Where:
-
-    -v|erbose:       Display progress output
-    -he|lp:          Display full help
-    -usa|ge:         Display usage
-    -[no]e|xec:      Whether or not to update JIRA. -noexec says only 
-                     tell me what you would have updated.
-    -use|rname:      Username to log into JIRA with (Default: jira-admin)
-    -p|assword:      Password to log into JIRA with (Default: jira-admin's 
-                     password)
-    -bugzillaserver: Machine where Bugzilla lives (Default: bugs-dev)
-    -jiraserver:     Machine where Jira lives (Default: jira-dev)
-    -bugi|ds:        Comma separated list of BugIDs to process
-    -f|ile:          File of BugIDs, one per line
-
-=head1 DESCRIPTION
-
-This script updates JIRA watchlists by copying the CC List information from
-Bugzilla to JIRA.
-
-=cut
-
-use FindBin;
-use lib "$FindBin::Bin/lib";
-
-$| = 1;
-
-use DBI;
-use Display;
-use Logger;
-use TimeUtils;
-use Utils;
-use JIRAUtils;
-use BugzillaUtils;
-use JIRA::REST;
-
-use Getopt::Long; 
-use Pod::Usage;
-
-# Login should be the email address of the bugzilla account which has 
-# priviledges to create products and components
-our %opts = (
-  exec           => 0,
-  bugzillaserver => $ENV{BUGZILLASERVER} || 'bugs-dev',
-  jiraserver     => $ENV{JIRASERVER}     || 'jira-dev:8081',
-  username       => 'jira-admin',
-  password       => 'jira-admin',
-  usage          => sub { pod2usage },
-  help           => sub { pod2usage (-verbose => 2)},
-  verbose        => sub { set_verbose },
-);
-
-our ($log, %total);
-
-my ($bugzilla, $jira);
-
-sub main () {
-  my $startTime = time;
-  
-  GetOptions (
-    \%opts,
-    'verbose',
-    'usage',
-    'help',
-    'exec!',
-    'quiet',
-    'username=s',
-    'password=s',
-    'bugids=s@',
-    'file=s',
-    'jiraserver=s',
-    'bugzillaserver=s',
-  ) or pod2usage;
-  
-  $log = Logger->new;
-  
-  if ($opts{file}) {
-    open my $file, '<', $opts{file} 
-      or die "Unable to open $opts{file} - $!";
-      
-    $opts{bugids} = [<$file>];
-    
-    chomp @{$opts{bugids}};
-  } else {
-    my @bugids;
-    
-    push @bugids, (split /,/, join (',', $_)) for (@{$opts{bugids}}); 
-  
-    $opts{bugids} = [@bugids];
-  } # if
-  
-  pod2usage 'Must specify -bugids <bugid>[,<bugid>,...] or -file <filename>'
-    unless $opts{bugids};
-
-  openBugzilla $opts{bugzillaserver}
-    or $log->err ("Unable to connect to $opts{bugzillaserver}", 1);
-    
-  Connect2JIRA ($opts{username}, $opts{password}, $opts{jiraserver})
-    or $log->err ("Unable to connect to $opts{jiraserver}", 1);
-  
-  for (@{$opts{bugids}}) {
-    my $issue = findIssue $_;
-    
-    if ($issue =~ /^Future JIRA Issue/ or $issue =~ /^Unable to find/) {
-      $log->msg ($issue);
-    } else {
-      my %watchers = getWatchers $_;
-      
-      $log->msg ('Found ' . scalar (keys %watchers) . " watchers for JIRA Issue $issue");
-      
-      my $result = updateIssueWatchers ($issue, %watchers);
-      
-      if ($result =~ /^Unable to/) {
-        $total{'Missing JIRA Issues'}++;
-        
-        $log->err ($result);
-      } else {
-        $total{'Issues updated'}++;
-      } # if
-    } # if
-  } # for
-
-  display_duration $startTime, $log;
-  
-  Stats (\%total, $log) unless $opts{quiet};
-  
-  return  0;
-} # main
-
-exit main;
diff --git a/etc/LDAP.conf b/etc/LDAP.conf
new file mode 100644 (file)
index 0000000..f5315da
--- /dev/null
@@ -0,0 +1,30 @@
+################################################################################
+#
+# File:         LDAP.conf
+# Description:  Configuration file for LDAP
+# Author:       Andrew DeFaria <Andrew@ClearSCM.com>
+# Version:      1.0
+# Created:      Fri Oct  3 18:16:26 PDT 2014
+# Modified:     $Date: 2014/10/03 18:17:20 $
+# Language:     Conf
+#
+# This file contains configurable parameters that are unique to your site that
+# identify the LDAP parms needed to retrieve user information from Active 
+# Directory. 
+#
+################################################################################
+AD_HOST:              ad-vip.audience.local
+AD_PORT:              389
+AD_BINDDN:            CN=jenkins cm,OU=Service Accounts,DC=audience,DC=local
+AD_BINDPW:            j55ln8Y6
+AD_BASEDN:            DC=audience,DC=local
+AD_PRINCIPAL:         audience.local
+
+#KNOWLES_AD_HOST:      kmvdcgc01.knowles.com
+KNOWLES_AD_HOST:      10.252.2.28
+KNOWLES_AD_PORT:      389
+KNOWLES_AD_BINDDN:    CN=AD Reader,OU=Users,OU=KMV,OU=Knowles,DC=knowles,DC=com
+KNOWLES_AD_BINDPW:    @Dre@D2015
+KNOWLES_AD_BASEDN:    DC=knowles,DC=com
+KNOWLES_AD_PRINCIPAL: knowles.com
+KNOWLES_AD_FILTER:    (sAMAccountName=%s)
diff --git a/rmc/Audience.png b/rmc/Audience.png
new file mode 100644 (file)
index 0000000..5420ea1
Binary files /dev/null and b/rmc/Audience.png differ
diff --git a/rmc/Makefile b/rmc/Makefile
new file mode 100644 (file)
index 0000000..45defae
--- /dev/null
@@ -0,0 +1,56 @@
+################################################################################
+#
+# File:         Makefile
+# Revision:     $Revision: 1 $
+# Description:  Makefile for Devops/Web/rmc
+# Author:       Andrew@Clearscm.com
+# Created:      Mon, Jun 01, 2015 12:19:02 PM
+# Modified:     $Date: 2012/09/20 06:52:37 $
+# Language:     Makefile
+#
+# (c) Copyright 2015, Audience, Inc., all rights reserved.
+#
+# Aside from the standard make targets, the following additional targets exist:
+#
+# setup:        Set up rmc web app
+#
+################################################################################
+include ../../make.inc
+
+WEBAPPS                := rmc
+HTTPCONF       := /etc/httpd/conf.d
+SERVER         := $(shell hostname -s)
+PORT            := 8000
+TEMPFILE        := $(shell mktemp --tmpdir $(TMP) -u rmc.conf.XXXX)
+
+define helpText
+Aside from the standard make targets, the following additional targets exist:\n\\n\
+install:      Set up rmc web app\n\
+uninstall:    Remove rmc web app\n
+endef
+
+all:
+       install
+
+help:
+       @echo -e "$(helpText)"
+
+test:
+       @read -p "Enter SERVER:" SERVER;\
+       echo "SERVER = $$SERVER";\
+       exit 1;
+
+install:
+       @read -p "Enter server name for this instance (Default: $(SERVER)):" SERVER; \
+       read -p "Enter port number for this instance (Default: $(PORT)):" PORT; \
+       $(SUDO) $(RMF) $(HTTPCONF)/rmc.conf; \
+       $(SED) "s/<SERVER>/$$SERVER/" rmc.conf > $(TEMPFILE); \
+       $(SED) "s/<PORT>/$$PORT/" $(TEMPFILE) > /tmp/rmc.conf; \
+       $(SUDO) $(RMF) $(TEMPFILE); \
+       $(SUDO) chown root.root /tmp/rmc.conf; \
+       $(SUDO) $(MV) /tmp/rmc.conf $(HTTPCONF)/rmc.conf; \
+       $(SUDO) $(SERVICE) httpd reload
+
+uninstall:
+       $(SUDO) $(RMF) $(HTTPCONF)/rmc.conf
+       $(SUDO) $(SERVICE) httpd reload
diff --git a/rmc/index.pl b/rmc/index.pl
new file mode 100644 (file)
index 0000000..be8403e
--- /dev/null
@@ -0,0 +1,1043 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+use CGI qw (
+  :standard
+  :cgi-lib 
+  start_div end_div 
+  *table 
+  start_Tr end_Tr
+  start_td end_td
+  start_pre end_pre
+  start_thead end_thead
+);
+
+=pod
+
+=head1 NAME rmc.pl
+
+Release Mission Control: Customized Release Notes
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #7 $
+
+=item Created
+
+Thu Mar 20 10:11:53 PDT 2014
+
+=item Modified
+
+$Date: 2015/07/22 $
+
+=back
+
+=head1 SYNOPSIS
+
+  $ rmc.pl [-username <username>] [-password <password>] 
+           [-client client] [-port] [-[no]html] [-csv]
+           [-comments] [-files] [-description]
+                
+           -from <revRange> [-to <revRange>]
+           [-branchpath <path>]
+                 
+           [-verbose] [-debug] [-help] [-usage]
+
+  Where:
+
+    -v|erbose:     Display progress output
+    -deb|ug:       Display debugging information
+    -he|lp:        Display full help
+    -usa|ge:       Display usage
+    -p|ort:        Perforce server and port (Default: Env P4PORT).
+    -use|rname:    Name of the user to connect to Perforce with with
+                   (Default:Env P4USER).
+    -p|assword:    Password for the user to connect to Perforce with
+                   (Default: Env P4PASSWD).
+    -cl|ient:      Perforce Client (Default: Env P4CLIENT)
+    -co|mments:    Include comments in output
+    -fi|les:       Include files in output
+    -cs|v:         Produce a csv file
+    -des|cription: Include description from Bugzilla
+    -fr|om:        From revSpec
+    -l|ong:        Shorthand for -comments & -files
+    -t|o:          To revSpec (Default: @now)
+    -b|ranchpath:  Path to limit changes to
+    -[no]ht|ml:    Whether or not to produce html output
+
+Note that revSpecs are Perforce's way of handling changelist/label/dates. For
+more info see p4 help revisions. For your reference:
+
+  #rev    - A revision number or one of the following keywords:
+  #none   - A nonexistent revision (also #0).
+  #head   - The current head revision (also @now).
+  #have   - The revision on the current client.
+  @change - A change number: the revision as of that change.
+  @client - A client name: the revision on the client.
+  @label  - A label name: the revision in the label.
+  @date   - A date or date/time: the revision as of that time.
+            Either yyyy/mm/dd or yyyy/mm/dd:hh:mm:ss
+            Note that yyyy/mm/dd means yyyy/mm/dd:00:00:00.
+            To include all events on a day, specify the next day.
+
+=head1 DESCRIPTION
+
+This script produces release notes on the web or in a .csv file. You can also
+produce an .html file by using -html and redirecting stdout.
+
+=cut
+
+use FindBin;
+use Getopt::Long;
+use Pod::Usage;
+
+use P4;
+
+use lib "$FindBin::Bin/../../Web/common/lib";
+use lib "$FindBin::Bin/../../common/lib";
+use lib "$FindBin::Bin/../../lib";
+use lib "$FindBin::Bin/../lib";
+
+use Display;
+use DateUtils;
+use JIRAUtils;
+use Utils;
+
+#use webutils;
+
+# Globals
+my $VERSION  = '$Revision: #7 $';
+  ($VERSION) = ($VERSION =~ /\$Revision: (.*) /);
+
+my $p4;
+my @labels;
+my $headerPrinted;
+my $p4ticketsFile = '/opt/audience/perforce/p4tickets';
+
+my $bugsweb = 'http://bugs.audience.local/show_bug.cgi?id=';
+my $p4web   = 'http://p4web.audience.local:8080';
+my $jiraWeb = 'http://jira.audience.local/browse/';
+my %opts;
+
+my $changesCommand = '';
+
+local $| = 1;
+
+my $title    = 'Release Mission Control';
+my $subtitle = 'Select from and to revspecs to see the bugs changes between them';
+my $helpStr  = 'Both From and To are Perforce '
+             . i ('revSpecs')
+             . '. You can use changelists, labels, dates or clients. For more'
+             . ' see p4 help revisions or '
+             . a {
+                 href   => 'http://www.perforce.com/perforce/r12.2/manuals/cmdref/o.fspecs.html#1047453',
+                 target => 'rmcHelp',
+               },
+               'Perforce File Specifications',
+             .  '.'
+             . br
+             . b ('revSpec examples') 
+             . ': &lt;change&gt;, &lt;client&gt;, &lt;label&gt, '
+             . '&lt;date&gt; - yyyy/mm/dd or yyyy/mm/dd:hh:mm:ss'
+             . br
+             . b ('Note:')
+             . ' To show all changes after label1 but before label2 use >label1 for From and @label2 for To. Or specify To as now';
+my @columnHeadings = (
+  '#',
+  'Changelist',
+  'Bug ID',
+  'Issue',
+  'Type',
+  'Status',
+  'Fix Versions',
+  'User ID',
+  'Date',
+  '# of Files',
+  'Summary',
+  'Checkin Comments',
+  'Files',
+);
+
+sub displayForm (;$$$);
+
+sub footing (;$) {
+  my ($startTime) = @_;
+  
+  print '<center>', a { 
+    href => url (-relative => 1). "?csv=1&from=$opts{from}&to=$opts{to}&branchpath=$opts{branchpath}",
+  }, 'Export CSV</center>' if $opts{from} or $opts{to};
+
+  print end_form;
+  
+  my $script = $FindBin::Script =~ /index.pl/
+             ? 'rmc.pl'
+             : $FindBin::Script;
+
+  my ($sec, $min, $hour, $mday, $mon, $year) = 
+    localtime ((stat ($script))[9]);
+
+  $year += 1900;
+  $mon++;
+
+  my $dateModified   = "$mon/$mday/$year @ $hour:$min";
+  my $secondsElapsed = $startTime ? time () - $startTime . ' secs' : '';
+
+  print end_div;
+
+  print start_div {-class => 'copyright'};
+    print "$script version $VERSION: Last modified: $dateModified";
+    print " ($secondsElapsed)" if $secondsElapsed;
+    print br "Copyright &copy; $year, Audience - All rights reserved - Design by ClearSCM";
+  print end_div;
+
+  print end_html;
+
+  return;
+} # footing
+
+sub errorMsg ($;$) {
+  my ($msg, $exit) = @_;
+
+  unless ($opts{html}) {
+    error ($msg, $exit);
+    
+    return
+  } # if
+  
+  unless ($headerPrinted) {
+    print header;
+    print start_html;
+    
+    $headerPrinted =1;
+  } # unless
+
+  print font ({class => 'error'}, '<br>ERROR: ') . $msg;
+
+  if ($exit) {
+    footing;
+    exit $exit;
+  } # if
+} # errorMsg
+
+sub debugMsg ($) {
+  my ($msg) = @_;
+  
+  return unless $opts{debug};
+
+  unless ($opts{html}) {
+    debug $msg;
+    
+    return
+  } # if
+  
+  unless ($headerPrinted) {
+    print header;
+    print start_html;
+    
+    $headerPrinted = 1;
+  } # unless
+
+  print font ({class => 'error'}, '<br>DEBUG: ') . $msg;
+} # debugMsg
+
+sub formatTimestamp (;$) {
+  my ($time) = @_;
+  
+  my $date          = YMDHMS ($time);
+  my $formattedDate = substr ($date, 0, 4) . '-'
+                    . substr ($date, 4, 2) . '-'
+                    . substr ($date, 6, 2) . ' '
+                    . substr ($date, 9);
+  
+  return $formattedDate;
+} # formatTimestamp
+
+sub p4errors ($) {
+  my ($cmd) = @_;
+
+  my $msg  = "Unable to run \"p4 $cmd\"";
+     $msg .= $opts{html} ? '<br>' : "\n";
+
+  if ($p4->ErrorCount) {
+    displayForm $opts{from}, $opts{to}, $opts{branchpath};
+
+    errorMsg $msg . $p4->Errors, $p4->ErrorCount;
+  } # if
+
+  return;
+} # p4errors
+
+sub p4connect () {
+  $p4 = P4->new;
+
+  $p4->SetUser     ($opts{username});
+  $p4->SetClient   ($opts{client}) if $opts{client};
+  $p4->SetPort     ($opts{port});
+  
+  if ($opts{username} eq 'shared') {
+       $p4->SetTicketFile ($p4ticketsFile);
+  } else {
+    $p4->SetPassword ($opts{password});
+  } # if
+
+  verbose_nolf "Connecting to Perforce server $opts{port}...";
+  $p4->Connect or die "Unable to connect to Perforce Server\n";
+  verbose 'done';
+
+  unless ($opts{username} eq 'shared') {
+    verbose_nolf "Logging in as $opts{username}\@$opts{port}...";
+
+    $p4->RunLogin;
+
+    p4errors 'login';
+
+    verbose 'done';
+  } # unless
+
+  return $p4;
+} # p4connect
+
+sub getChanges ($;$$) {
+  my ($from, $to, $branchpath) = @_;
+
+  $from = "\@$from" unless $from =~ /^@/;
+  $to   = "\@$to"   unless $to   =~ /^@/;
+  
+  my $args;
+     #$args    = '-s shelved ';
+     $args   .= $branchpath if $branchpath;
+     $args   .= $from;
+     $args   .= ",$to" if $to;
+
+  my $cmd     = 'changes';
+  my $changes = $p4->Run ($cmd, $args);
+  
+  $changesCommand = "p4 $cmd $args" if $opts{debug};
+  
+  p4errors "$cmd $args";
+  
+  unless (@$changes) {
+    if ($to =~ /\@now/i) {
+      verbose "No changes since $from";
+    } else {
+      verbose "No changes between $from - $to";
+    } # if
+
+    return;
+  } else {
+    return @$changes;
+  } # unless
+} # getChanges
+
+sub getJobInfo ($) {
+  my ($job) = @_;
+
+  my $jobs = $p4->IterateJobs ("-e $job");
+
+  p4errors "jobs -e $job";
+
+  $job = $jobs->next if $jobs->hasNext;
+
+  return $job;
+} # getJobInfo
+
+sub getComments ($) {
+  my ($changelist) = @_;
+
+  my $change = $p4->FetchChange ($changelist);
+
+  p4errors "change $changelist";
+
+  return $change->{Description};
+} # getComments
+
+sub getFiles ($) {
+  my ($changelist) = @_;
+
+  my $files = $p4->Run ('files', "\@=$changelist");
+
+  p4errors "files \@=$changelist";
+
+  my @files;
+
+  push @files, $_->{depotFile} . '#' . $_->{rev} for @$files;
+
+  return @files;
+} # getFiles
+
+sub displayForm (;$$$) {
+  my ($from, $to, $branchpath) = @_;
+
+  $from //= '<today>';
+  $to   //= '<today>';
+
+  print p {align => 'center', class => 'dim'}, $helpStr;
+
+  print start_form {
+    method  => 'get',
+    actions => $FindBin::Script,
+  };
+
+  print start_table {
+    class       => 'table',
+    align       => 'center',
+    cellspacing => 1,
+    width       => '95%',
+  };
+
+  print Tr [th ['From', 'To']];
+  print start_Tr;
+  print td {align => 'center'}, textfield (
+    -name => 'from',
+    value => $from,
+    size  => 60,
+  );
+  print td {align => 'center'}, textfield (
+    -name => 'to',
+    value => $to,
+    size  => 60,
+  );
+  print end_Tr;
+  
+  print Tr [th {colspan => 2}, 'Branch/Path'];
+  print start_Tr;
+  print td {
+    colspan => 2,
+    align   => 'center',
+  }, textfield (
+    -name => 'branchpath',
+    value => $branchpath,
+    size  => 136,
+  );
+  print end_Tr;
+
+  print start_Tr;
+  print td {align => 'center', colspan => 2}, b ('Options:'), checkbox (
+    -name   => 'comments',
+    id      => 'comments',
+    onclick => 'toggleOption ("comments");',
+    label   => 'Comments',
+    checked => $opts{comments} ? 'checked' : '',
+    value   => $opts{comments},
+  ), checkbox (
+    -name   => 'files',
+    id      => 'files',
+    onclick => 'toggleOption ("files");',
+    label   => 'Files',
+    checked => $opts{files} ? 'checked' : '',
+    value   => $opts{files},
+#  ), checkbox (
+#    -name   => 'group',
+#    id      => 'group',
+#    onclick => 'groupIndicate();',
+#    label   => 'Group Indicate',
+#    checked => 'checked',
+#    value   => $opts{group},
+  );
+
+  print end_Tr;
+
+  print start_Tr;
+  print td {align => 'center', colspan => 2}, input {
+    type  => 'Submit',
+    value => 'Submit',
+  };
+  print end_Tr;
+  print end_table;
+  print p;
+
+  return;
+} # displayForm
+
+sub displayChangesHTML (@) {
+  my (@changes) = @_;
+
+  displayForm $opts{from}, $opts{to}, $opts{branchpath};
+
+  unless (@changes) {
+    my $msg  = "No changes found between $opts{from} and $opts{to}";
+       $msg .= " for $opts{branchpath}"; 
+    print p $msg;
+
+    return;
+  } # unless
+
+  my $displayComments = $opts{comments} ? '' : 'none';
+  my $displayFiles    = $opts{files}    ? '' : 'none';
+
+  debugMsg "Changes command used: $changesCommand";
+  
+  print start_table {
+    class => 'table-autosort',
+    align => 'center',
+    width => '95%',
+  };
+  
+  print start_thead;
+  print start_Tr;
+  print th '#';
+  print 
+  th {
+    class => 'table-sortable:numeric table-sortable',
+    title => 'Click to sort',
+  }, 'Changelist',
+  th {
+    class => 'table-sortable:numeric',
+    title => 'Click to sort',
+  }, 'Bug ID',
+  th {
+    class => 'table-sortable:numeric',
+    title => 'Click to sort',
+  }, 'Issue',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Type',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Status',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Fix Version',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'User ID',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Date',
+  th {
+    class => 'table-sortable:numeric table-sortable',
+    title => 'Click to sort',
+  }, '# of Files',
+  th 'Summary',
+  th {
+    id    => 'comments0',
+    style => "display: $displayComments",
+  }, 'Checkin Comments',
+  th {
+    id    => 'files0',
+    style => "display: $displayFiles",
+  }, 'Files';
+  print end_Tr;
+  print end_thead;
+  
+  my $i = 0;
+  
+  for (sort {$b->{change} <=> $a->{change}} @changes) {
+    my %change = %$_;
+
+    my @files = getFiles $change{change};
+    
+    my $job;
+    
+    for ($p4->Run ('fixes', "-c$change{change}")) { # Why am I uses fixes here?
+       $job = getJobInfo $_->{Job};
+       last; # FIXME - doesn't handle muliple jobs.
+    } # for
+
+    $job->{Description} = font {color => '#aaa'}, 'N/A' unless $job;
+
+    my ($bugid, $jiraIssue);
+    
+    if ($job->{Job}) {
+      if ($job->{Job} =~ /^(\d+)/) {
+        $bugid = $1;
+      } elsif ($job->{Job} =~ /^(\w+-\d+)/) {
+        $jiraIssue = $1;
+      }
+    } # if
+
+    # Using the following does not guarantee the ordering of the elements 
+    # emitted!
+    #
+    # my $buglink  = a {
+    #   href   => "$bugsweb$bugid",
+    #   target => 'bugzilla',
+    # }, $bugid;
+    #
+    # IOW sometimes I get <a href="..." target="bugzilla"> and other times I 
+    # get <a target="bugzilla" href="...">! Not cool because the JavaScript
+    # later on is comparing innerHTML and this messes that up. So we write this
+    # out by hand instead.
+    my $buglink     = $bugid 
+                    ? "<a href=\"$bugsweb$bugid\" target=\"bugzilla\">$bugid</a>"
+                    : font {color => '#aaa'}, 'N/A';
+    my $jiralink    = $jiraIssue
+                    ? "<a href=\"$jiraWeb$jiraIssue\" target=\"jira\">$jiraIssue</a>"
+                    : font {color => '#aaa'}, 'N/A';                  
+    my $cllink      = "<a href=\"$p4web/$_->{change}?ac=133\" target=\"p4web\">$_->{change}</a>";
+    my $userid      = $_->{user};
+    my $description = $job->{Description};
+    my $jiraStatus  = font {color => '#aaa'}, 'N/A';
+    my $issueType   = font {color => '#aaa'}, 'N/A';
+    my $fixVersions = font {color => '#aaa'}, 'N/A';
+    
+    if ($jiraIssue) {
+      my $issue;
+      
+      eval {$issue = getIssue ($jiraIssue, qw (status issuetype fixVersions))};
+      
+      unless ($@) {
+        $jiraStatus = $issue->{fields}{status}{name};
+        $issueType  = $issue->{fields}{issuetype}{name};
+        
+        my @fixVersions;
+        
+        push @fixVersions, $_->{name} for @{$issue->{fields}{fixVersions}};
+          
+        $fixVersions = join '<br>', @fixVersions; 
+      } # unless
+    } # if
+    
+    print start_Tr {id => ++$i};
+        
+    # Attempting to "right size" the columns...
+    print 
+      td {width => '10px',  align => 'center'},                       $i,
+      td {width => '15px',  align => 'center', id => "changelist$i"}, $cllink,
+      td {width => '60px',  align => 'center', id => "bugzilla$i"},   $buglink,
+      td {width => '80px',  align => 'center', id => "jira$i"},       $jiralink,
+      td {width => '50px',  align => 'center', id => "type$i"},       $issueType,
+      td {width => '50px',  align => 'center', id => "jirastatus$i"}, $jiraStatus,
+      td {width => '50px',  align => 'center', id => "fixVersion$i"}, $fixVersions,
+      td {width => '30px',  align => 'center', id => "userid$i"},     a {href => "mailto:$userid\@audience.com" }, $userid,
+      td {width => '130px', align => 'center'},                       formatTimestamp ($_->{time}),
+      td {width => '10px',  align => 'center'},                       scalar @files;
+      
+    if ($description =~ /N\/A/) {
+      print td {id => "description$i", align => 'center'}, $description;
+    } else {
+      print td {id => "description$i"}, $description;
+    } # if
+      
+    print
+      td {id     => "comments$i",
+          valign => 'top',
+          style  => "display: $displayComments",
+      }, pre {class => 'code'}, getComments ($_->{change});
+
+    print start_td {
+      id      => "files$i",
+      valign  => 'top',
+      style   => "display: $displayFiles"
+    };
+
+    print start_pre {class => 'code'};
+
+    for my $file (@files) {
+      my ($filelink) = ($file =~ /(.*)\#/);
+      my ($revision) = ($file =~ /\#(\d+)/);
+      
+      # Note: For a Perforce "Add to Source Control" operation, revision is 
+      # actually "none". Let's fix this.
+      $revision = 1 unless $revision;
+      
+      if ($revision == 1) {
+        print a {
+          href   => '#',
+        }, img {
+          src   => "$p4web/rundiffprevsmallIcon?ac=20",
+          title => "There's nothing to diff since this is the first revision",
+        };
+      } else {
+        print a {
+          href   => "$p4web$filelink?ac=19&rev1=$revision&rev2=" . ($revision - 1),
+          target => 'p4web',
+        }, img {
+          src    => "$p4web/rundiffprevsmallIcon?ac=20",
+          title  => "Diff rev #$revision vs. rev #" . ($revision -1),
+        };
+      } # if
+      
+      print a {
+        href   => "$p4web$filelink?ac=22",
+        target => 'p4web',
+      }, "$file<br>";
+    } # for
+
+    print end_pre;
+    print end_td;
+
+    print end_Tr;
+  } # for
+
+  print end_table;
+
+  return;
+} # displayChangesHTML
+
+sub displayChange (\%;$) {
+  my ($change, $nbr) = @_;
+  
+  $nbr //= 1;
+  
+  # Note: $change must about -c!
+  my $args  = "-c$change->{change}";
+  my $cmd   = 'fixes';
+  my $fix = $p4->Run ($cmd, $args);
+
+  p4errors "$cmd $args";
+
+  errorMsg "Change $change->{change} is associated with multiple jobs. This case is not handled yet" if @$fix > 1;
+
+  $fix = $$fix[0];
+  
+  # If there was no fix associated with this change we will use the change data.
+  unless ($fix) {
+    $fix->{Change} = $change->{change}; 
+    $fix->{User}   = $change->{user};
+    $fix->{Date}   = $change->{time};
+  } # unless
+  
+  my $job;
+  $job = getJobInfo ($fix->{Job}) if $fix->{Job};
+  
+  unless ($job) {
+    chomp $change->{desc};
+
+    $job = {
+      Description => $change->{desc},
+      Job         => 'Unknown',
+    };
+  } # unless
+
+  my ($bugid)  = ($job->{Job} =~ /^(\d+)/);
+  
+  chomp $job->{Description};
+  
+  my $description  = "$change->{change}";
+     $description .= "/$bugid" if $bugid;
+     $description .= ": $job->{Description} ($fix->{User} ";
+     $description .= ymdhms ($fix->{Date}) . ')';
+
+  display $nbr++ . ") $description";
+
+  if ($opts{comments}) {
+    print "\n";
+    print "Comments:\n" . '-'x80 . "\n" . getComments ($fix->{Change}) . "\n";
+  } # if
+
+  if ($opts{description}) {
+    display '';
+    display "Description:\n" . '-'x80 . "\n" . $job->{Description};
+  } # if
+
+  if ($opts{files}) {
+    display "Files:\n" . '-'x80;
+
+    for (getFiles $fix->{Change}) {
+      display "\t$_";
+    } # for
+
+    display '';
+  } # if
+
+  return $nbr;
+} # displayChangesHTML
+
+sub displayChanges (@) {
+  my (@changes) = @_;
+
+  unless (@changes) {
+    my $msg  = "No changes found between $opts{from} and $opts{to}";
+       $msg .= " for $opts{branchpath}"; 
+    print p $msg;
+
+    return;
+  } # unless
+
+  my $i;
+  
+  debugMsg "Changes command used: $changesCommand";
+  
+  $i = displayChange %$_, $i for @changes;
+    
+  return;
+} # displayChanges
+
+sub heading ($;$) {
+  my ($title, $subtitle) = @_;
+
+  print header unless $headerPrinted;
+
+  $headerPrinted = 1;
+
+  print start_html {
+    title   => $title,
+    head    => Link ({
+      rel   => 'shortcut icon',
+      href  => 'http://p4web.audience.local:8080/favicon.ico',
+      type  => 'image/x-icon',
+    }),
+    author  => 'Andrew DeFaria <Andrew@ClearSCM.com>',
+    script  => [{
+      language => 'JavaScript',
+      src      => 'rmc.js',
+    }, {
+      language => 'JavaScript',
+      src      => 'rmctable.js',
+    }],
+    style      => ['rmc.css'],
+    onload     => 'setOptions();',
+  }, $title;
+  
+  print h1 {class => 'title'}, "<center><img src=\"Audience.png\"> $title</center>";
+  print h3 "<center><font color='#838'>$subtitle</font></center>" if $subtitle;
+
+  return;
+} # heading
+
+sub exportCSV ($@) {
+  my ($filename, @data) = @_;
+
+  print header (
+    -type       => 'application/octect-stream',
+    -attachment => $filename,
+  );
+
+  # Print heading line
+  my $columns;
+  
+  # Note that we do not include the '#' column so start at 1
+  for (my $i = 1; $i < @columnHeadings; $i++) {
+    $columns .= "\"$columnHeadings[$i]\"";
+    
+    $columns .= ',' unless $i == @columnHeadings;
+  } # for
+  
+  print "$columns\n";
+  
+  for (sort {$b->{change} <=> $a->{change}} @data) {
+    my %change = %$_;
+
+    ## TODO: This code is duplicated (See displayChange). Consider refactoring.
+    # Note: $change must be right next to the -c!
+    my (%job, $jiraStatus, $issueType, $fixVersions);
+    
+    for ($p4->Run ('fixes', "-c$change{change}")) {
+       %job = %{getJobInfo $_->{Job}};
+       last; # FIXME - doesn't handle muliple jobs.
+    } # for
+
+    $job{Description} = '' unless %job;
+
+    my ($bugid, $jiraIssue);
+    
+    if ($job{Job}) {
+      if ($job{Job} =~ /^(\d+)/) {
+        $bugid = $1;
+      } elsif ($job{Job} =~ /^(\w+-\d+)/) {
+        $jiraIssue = $1;
+      }
+    } # if    
+  
+    if ($jiraIssue) {
+      my $issue;
+      
+      eval {$issue = getIssue ($jiraIssue, qw (status issuetype fixVersions))};
+      
+      unless ($@) {
+        $jiraStatus = $issue->{fields}{status}{name};
+        $issueType  = $issue->{fields}{issuetype}{name};
+
+        my @fixVersions;
+        
+        push @fixVersions, $_->{name} for @{$issue->{fields}{fixVersions}};
+          
+        $fixVersions = join "\n", @fixVersions; 
+      } # unless
+    } # if
+
+    my $job;
+    
+    unless ($job) {
+      chomp $change{desc};
+  
+      $job = {
+        Description => $change{desc},
+        Job         => 'Unknown',
+      };
+    } # unless
+    ## End of refactor code
+
+    $job{Description} = join ("\r\n", split "\n", $job{Description});
+    
+    my @files    = getFiles $change{change};
+    my $comments = join ("\r\n", split "\n", getComments ($change{change}));
+    
+    # Fix up double quotes in description and comments
+    $job{Description} =~ s/"/""/g;
+    $comments         =~ s/"/""/g;
+    
+    print "$change{change},";
+    print $bugid       ? "$bugid,"       : ',';
+    print $jiraIssue   ? "$jiraIssue,"   : ',';
+    print $issueType   ? "$issueType,"   : ',';
+    print $jiraStatus  ? "$jiraStatus,"  : ',';
+    print $fixVersions ? "$fixVersions," : ',';
+    print "$change{user},";
+    print '"' . formatTimestamp ($change{time}) . '",';
+    print scalar @files . ',';
+    print "\"$job{Description}\",";
+    print "\"$comments\",";
+    print '"' . join ("\n", @files) . "\"";
+    print "\n";
+  } # for
+  
+  return;
+} # exportCSV
+
+sub main {
+  # Standard opts
+  $opts{usage}    = sub { pod2usage };
+  $opts{help}     = sub { pod2usage (-verbose => 2)};
+
+  # Opts for this script
+  $opts{username}   //= $ENV{P4USER}         || $ENV{USERNAME}    || $ENV{USER};
+  $opts{client}     //= $ENV{P4CLIENT};
+  $opts{port}       //= $ENV{P4PORT}         || 'perforce:1666';
+  $opts{password}   //= $ENV{P4PASSWD};
+  $opts{html}       //= $ENV{HTML}           || param ('html')    || 1;
+  $opts{html}         = (-t) ? 0 : 1;
+  $opts{debug}      //= $ENV{DEBUG}          || param ('debug')   || sub { set_debug };
+  $opts{verbose}    //= $ENV{VERBOSE}        || param ('verbose') || sub { set_verbose };
+  $opts{jiraserver} //= 'jira';
+  $opts{from}         = param 'from';
+  $opts{to}           = param 'to';
+  $opts{branchpath}   = param ('branchpath') || '//AudEngr/Import/VSS/...';
+  $opts{group}        = param 'group';
+  $opts{comments}   //= $ENV{COMMENTS}       || param 'comments';
+  $opts{files}      //= $ENV{FILES}          || param 'files';
+  $opts{long}       //= $ENV{LONG}           || param 'long';
+  $opts{csv}        //= $ENV{CSV}            || param 'csv';
+  
+  GetOptions (
+    \%opts,
+    'verbose',
+    'debug',
+    'help',
+    'usage',
+    'port=s',
+    'username=s',
+    'password=s',
+    'client=s',
+    'comments',
+    'files',
+    'description',
+    'long',
+    'from=s',
+    'to=s',
+    'branchpath=s',
+    'html!',
+    'csv',
+  ) || pod2usage;
+
+  $opts{comments} = $opts{files} = 1 if $opts{long};
+  $opts{debug}    = get_debug        if ref $opts{debug}   eq 'CODE';
+  $opts{verbose}  = get_verbose      if ref $opts{verbose} eq 'CODE';
+
+  # Are we doing HTML?
+  if ($opts{html}) {
+    require CGI::Carp;
+
+    CGI::Carp->import ('fatalsToBrowser');
+
+    $opts{username} ||= 'shared';
+  } # if
+
+  # Needed if using the shared user
+  if ($opts{username} eq 'shared') {
+    unless (-f $p4ticketsFile) {
+      errorMsg "Using 'shared' user but there is no P4TICKETS file ($p4ticketsFile)", 1;
+    } # unless
+  } else {
+    if ($opts{username} and not $opts{password}) {
+      $opts{password} = GetPassword "I need the Perforce password for $opts{username}";
+    } # if
+  } # if
+
+  p4connect;
+  
+  my $jira = Connect2JIRA (undef, undef, $opts{jiraserver});
+
+  unless ($opts{from} or $opts{to}) {
+    if ($opts{html}) {
+      heading $title, $subtitle;
+
+      displayForm $opts{from}, $opts{to}, $opts{branchpath};
+
+      footing;
+
+      exit;
+    } # if
+  } # unless
+  
+  my $ymd = YMD;
+  my $midnight = substr ($ymd, 0, 4) . '/'
+               . substr ($ymd, 4, 2) . '/'
+               . substr ($ymd, 6, 2) . ':00:00:00';
+
+  $opts{to}   //= 'now';
+  $opts{from} //= $midnight;
+
+  $opts{to}     = 'now'     if ($opts{to}   eq '<today>' or $opts{to}   eq '');
+  $opts{from}   = $midnight if ($opts{from} eq '<today>' or $opts{from} eq '');
+
+  my $msg = 'Changes made ';
+  
+  if ($opts{to} =~ /^now$/i and $opts{from} eq $midnight) {
+    $msg .= 'today';
+  } elsif ($opts{to} =~ /^now$/i) {
+    $msg .= "since $opts{from}";
+  } else {
+    $msg .= "between $opts{from} and $opts{to}";
+  } # if
+
+  if ($opts{csv}) {
+    my $filename = "$opts{from}_$opts{to}.csv";
+    
+    $filename =~ s/\//-/g;
+    $filename =~ s/\@//g;
+    
+    debug "branchpath = $opts{branchpath}";
+    
+    exportCSV $filename, getChanges $opts{from}, $opts{to}, $opts{branchpath};
+    
+    return;
+  } # if
+
+  if ($opts{html}) {
+    heading 'Release Mission Control', $msg;
+  } else {
+    display "$msg\n";
+  } # if
+
+  if ($opts{html}) {
+    my $startTime = time;
+    displayChangesHTML getChanges $opts{from}, $opts{to}, $opts{branchpath};  
+    
+    footing $startTime;
+  } else {
+    displayChanges getChanges $opts{from}, $opts{to}, $opts{branchpath};
+  } # if
+
+  return;
+} # main
+
+main;
+
+exit;
diff --git a/rmc/rmc.conf b/rmc/rmc.conf
new file mode 100644 (file)
index 0000000..e35ff64
--- /dev/null
@@ -0,0 +1,34 @@
+################################################################################
+#
+# File:         rmc.conf
+# Revision:     $Revision: 1 $
+# Description:  Apache conf file for RMC
+# Author:       Andrew@Clearscm.com
+# Created:      Mon, Jun 01, 2015 12:19:02 PM
+# Modified:     $Date: 2012/09/20 06:52:37 $
+# Language:     Apache
+#
+# (c) Copyright 2015, Audience, Inc., all rights reserved.
+#
+# This file defines the RMC web app for Apache. Generally it is symlinked into
+# /etc/httpd/conf.d
+#
+################################################################################
+Listen <PORT>
+
+<VirtualHost *:<PORT>>
+  ServerName  <SERVER>.audience.local:<PORT>
+  ServerAlias <SERVER>
+  ErrorLog  "/var/log/httpd/rmc.error.log"
+  CustomLog "/var/log/httpd/rmc.access.log" common
+  DocumentRoot  "/opt/audience/Web/rmc"
+
+  <Directory "/opt/audience/Web/rmc">
+    Options Indexes MultiViews FollowSymLinks ExecCGI
+    DirectoryIndex index.html index.pl
+    AllowOverride None
+    Order allow,deny
+    Allow from all
+    AddHandler cgi-script .pl
+  </Directory>
+</VirtualHost>
diff --git a/rmc/rmc.css b/rmc/rmc.css
new file mode 100644 (file)
index 0000000..0ee6b8b
--- /dev/null
@@ -0,0 +1,355 @@
+/*******************************************************************************
+*
+* File:        $RCSfile: rmc.css,v $
+* Revision:    $Revision: 1 $
+* Description: Cascading Style Sheet definitions for rmc
+* Author:      Andrew@ClearSCM.com
+* Created:     Tue May 27 20:48:21 PDT 2014
+* Modified:    $Date: $
+* Language:    Cascading Style Sheet
+*
+* (c) Copyright 2014, Audience, all rights reserved.
+*
+*******************************************************************************/
+body {
+  font-size: 11px;
+  color: black;
+  /*background-color:#f1f1ed;*/
+  margin: 0px;
+  /*padding: 0; */
+  /*overflow:scroll;*/
+}
+
+body,p,h1,h2,h3,table,td,th,ul,ol,textarea,input {
+  font-family: verdana, helvetica, arial, sans-serif;
+}
+
+h1.title {
+  font-size: 3em;
+  color: #838;
+  text-shadow: 3px 2px 5px #666;
+}
+.nobr {
+  white-space: nowrap;
+  wrap: off;
+}
+
+table.noborder {
+  border: none;
+}
+
+table.noborder td {
+  border: none;
+}
+
+td.noboder {
+  border-top: none;
+}
+
+table.invisible1 {
+  border: none;
+  border-collapse: collapse;
+}
+
+.indent {
+  display: block;
+  margin-left: 50px;
+}
+
+table {
+  border: 1px solid #c3c3c3;
+  padding: 3px;
+  border-collapse: collapse;
+}
+
+table.framework {
+  /*border:none;*/
+  border-collapse: collapse;
+  /*width:100%;*/
+}
+
+table.frameworktop {
+  border: none;
+  border-collapse: collapse;
+  width: 100%;
+}
+
+tr.highlighthover:hover {
+  background-color: #FFF8F0;
+  color: #000;
+}
+
+td.highlighthover:hover {
+  background-color: #F8F0F0;
+  color: #000;
+}
+
+caption {
+  font-size: 130%;
+  font-weight: bold;
+  text-align: left;
+  padding-bottom: 2px;
+}
+
+.envbanner {
+  color: RED;
+  font-weight: bold;
+  font-style: Italic;
+}
+
+.statuserrorfont {
+  color: RED;
+  /*font-weight:Bold;*/
+}
+
+.statusinprogressfont {
+  color: Blue;
+  font-style: Italic
+}
+
+.statusgoodfont {
+  color: #10B618;
+}
+
+span.rightalign {
+  /* display:block; */
+  display: block;
+  text-align: right;
+  padding: 0px;
+  margin: 0px;
+}
+
+table th {
+  vertical-align: top;
+  text-align: center;
+  border: 1px solid #c3c3c3;
+  padding: 3px;
+  background-color: #f0f0f0;
+}
+
+table td {
+  vertical-align: top;
+  /*text-align: left;*/
+  border: 1px solid #c3c3c3;
+  padding: 3px;
+}
+
+/* Sorting */
+th.table-sortable {
+    cursor:pointer;
+    /*background-image:url("/icons/sortable.gif");*/
+    background-position:center left;
+    background-repeat:no-repeat;
+    padding-left:12px;
+}
+th.table-sorted-asc {
+    background-image:url("/icons/sorted_up.gif");
+    background-position:center left;
+    background-repeat:no-repeat;
+}
+th.table-sorted-desc {
+    background-image:url("/icons/sorted_down.gif");
+    background-position:center left;
+    background-repeat:no-repeat;
+}
+
+table.framework td.frameworkleft /*td.frameworkleft */ {
+  /*background: #ffff00;*/
+  width: 15em;
+  min-width: 15em;
+  max-width: 15em;
+  vertical-align: top;
+  border-collapse: collapse;
+  border-left-width: 0px;
+  border-right-width: 1px;
+  border-top-width: 0px;
+  border-bottom-width: 0px;
+  /*width:15%;*/
+  /*max-width:80px;*/
+}
+
+table.framework td.frameworkcenter {
+  /*border:3px red;*/
+  border-collapse: collapse;
+  border-left-width: 0px;
+  border-right-width: 0px;
+  border-top-width: 0px;
+  border-bottom-width: 0px;
+  /*width:75%;*/
+}
+
+table.frameworktop td.frameworktop {
+  border-collapse: collapse;
+  border: none;
+}
+
+table.framework td.frameworkright {
+  border-collapse: collapse;
+  border-left-width: 0px;
+  border-right-width: 0px;
+  border-top-width: 0px;
+  border-bottom-width: 0px;
+  width: 5%; /* modify if there is content */
+}
+smallfiltericon {
+  width: 8px;
+  min-width: 8px;
+  max-width: 8px;
+  width: 8px;
+  visibility: hidden;
+}
+
+h1,h2,h3 {
+  background-color: transparent;
+  color: #000000;
+  margin-bottom: 3px;
+}
+
+pre {
+  font-family: "Courier New", monospace;
+  margin-left: 0;
+  margin-bottom: 0;
+  white-space: pre-wrap;
+}
+
+span.show {
+  display: block;
+}
+
+span.hide {
+  visibility: hidden;
+}
+
+a:link {
+  text-decoration: none;
+  color: #5150F7;
+}
+
+a:visited {
+  color: #8E0094;
+}
+
+a:hover,a:active {
+  text-decoration: none;
+  border-bottom-width: 1px;
+  border-bottom-style: dotted;
+}
+
+a.downloadgood {
+  color: #10B618;
+}
+
+
+hr {
+  background-color: #d4d4d4;
+  color: #d4d4d4;
+  height: 1px;
+  border: 0px;
+}
+
+
+span.insert {
+  color: #e80000;
+  background-color: transparent;
+}
+
+span.highlight_code {
+  color: #e80000;
+  background-color: transparent;
+}
+
+a.m_item:link {
+  text-decoration: none;
+  color: white;
+  background-color: transparent
+}
+
+a.m_item:visited {
+  text-decoration: none;
+  color: white;
+  background-color: transparent
+}
+
+a.m_item:active {
+  text-decoration: underline;
+  color: white;
+  background-color: transparent
+}
+
+a.m_item:hover {
+  text-decoration: underline;
+  color: white;
+  background-color: transparent
+}
+
+a.bold {
+  font-weight: bold;
+}
+
+table.noborder td {
+  border: none;
+}
+
+table.dashboardtable th {
+  border: none;
+  background-color: none;
+  border-bottom: 1px solid #000000;
+  border-top: 1px solid #000000;
+}
+
+div.showdiv {
+  display: block;
+}
+
+div.hidediv {
+  display: none;
+}
+
+div.inline {
+  display: inline;
+}
+
+greyfont {
+  font-color: red;
+}
+
+img {
+  border: none;
+}
+
+/* Copyright block */
+.copyright {
+  border-bottom:1px dotted #ccc;
+  border-top:1px dotted #ccc;
+  color:#999;
+  font-family:verdana, arial, sans-serif;
+  font-size:10px;
+  margin-top:5px;
+  text-align:center;
+  width:auto;
+}
+
+.copyright a:link, a:visited {
+  color:#666;
+  font-weight:bold;
+  text-decoration:none;
+}
+
+.copyright a:hover {
+  color:#333;
+}
+
+.error {
+  color:red;
+  font-weight:bold;
+}
+
+.dim {
+  color: #aaa;
+}
+
+pre.code {
+  display: inline;
+  white-space: pre-line;
+  font-family: monospace;
+}
\ No newline at end of file
diff --git a/rmc/rmc.js b/rmc/rmc.js
new file mode 100644 (file)
index 0000000..49d5343
--- /dev/null
@@ -0,0 +1,107 @@
+var comments;
+var files;
+
+function setOptions () {
+  comments = document.getElementById ("comments").checked;
+  files    = document.getElementById ("files").checked;
+  //group    = document.getElementById ("group").checked;
+} // setOptions
+
+function colorLines () {
+  return; // not used
+  if (comments || files) {
+    color = '#ffc';
+  } else {
+    color = 'white';
+  } // if
+  
+  i = 1;
+  
+  while (element = document.getElementById (i++)) {
+    element.style.backgroundColor = color;
+  } // while
+} // colorLines
+
+function hideElement (elementName) {
+  var i = 0;
+  
+  while ((element = document.getElementById (elementName + i++)) != null) {
+    element.style.display = "none";
+  } // while
+} // hideElement
+
+function showElement (elementName) {
+  var i = 0;
+
+  while ((element = document.getElementById (elementName + i++)) != null) {
+    element.colSpan = 7;
+    element.style.display = "";
+  } // while
+} // showElement
+
+function toggleOption (option) {
+  if (option == "comments") {
+    if (comments) {
+      hideElement (option);
+      
+      comments = false;
+    } else {
+      showElement (option);
+      
+      comments = true;
+    } // if
+  } else if (option == "files") {
+    if (files) {
+      hideElement (option);
+      
+      files = false;
+    } else {
+      showElement (option);
+      
+      files = true;
+    } // if
+  } // if
+  
+  //colorLines ();
+} // toggleOption
+
+function groupIndicate () {
+  var fields = ['bugzilla', 'changelist', 'userid', 'summary'];
+  var values = [];
+  
+  // Seed values
+  for (var i = 0; i < fields.length; i++) {
+    values[fields[i]] = document.getElementById (fields[i] + 1).innerHTML;
+  } // for
+  
+  i = 1;
+  
+  while (document.getElementById (i) != null) {
+    i++;
+    
+    for (var j = 0; j < fields.length; j++) {
+      var element =  document.getElementById (fields[j] + i);
+      
+      if (element == null) break;
+      
+      if (group) {
+        if (element.innerHTML == values[fields[j]]) {
+          element.innerHTML = '';
+        } else {
+          values[fields[j]] = element.innerHTML;
+        } // if
+      } else {
+        if (element.innerHTML == '') {
+          element.innerHTML = values[fields[j]];
+        } // if
+      } // if
+    } // for
+  } // while
+  
+  // Toggle group
+  if (group) {
+    group = false;
+  } else {
+    group = true;
+  } // if
+} // groupIndicate
\ No newline at end of file
diff --git a/rmc/rmc.pl b/rmc/rmc.pl
new file mode 100644 (file)
index 0000000..be8403e
--- /dev/null
@@ -0,0 +1,1043 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+use CGI qw (
+  :standard
+  :cgi-lib 
+  start_div end_div 
+  *table 
+  start_Tr end_Tr
+  start_td end_td
+  start_pre end_pre
+  start_thead end_thead
+);
+
+=pod
+
+=head1 NAME rmc.pl
+
+Release Mission Control: Customized Release Notes
+
+=head1 VERSION
+
+=over
+
+=item Author
+
+Andrew DeFaria <Andrew@ClearSCM.com>
+
+=item Revision
+
+$Revision: #7 $
+
+=item Created
+
+Thu Mar 20 10:11:53 PDT 2014
+
+=item Modified
+
+$Date: 2015/07/22 $
+
+=back
+
+=head1 SYNOPSIS
+
+  $ rmc.pl [-username <username>] [-password <password>] 
+           [-client client] [-port] [-[no]html] [-csv]
+           [-comments] [-files] [-description]
+                
+           -from <revRange> [-to <revRange>]
+           [-branchpath <path>]
+                 
+           [-verbose] [-debug] [-help] [-usage]
+
+  Where:
+
+    -v|erbose:     Display progress output
+    -deb|ug:       Display debugging information
+    -he|lp:        Display full help
+    -usa|ge:       Display usage
+    -p|ort:        Perforce server and port (Default: Env P4PORT).
+    -use|rname:    Name of the user to connect to Perforce with with
+                   (Default:Env P4USER).
+    -p|assword:    Password for the user to connect to Perforce with
+                   (Default: Env P4PASSWD).
+    -cl|ient:      Perforce Client (Default: Env P4CLIENT)
+    -co|mments:    Include comments in output
+    -fi|les:       Include files in output
+    -cs|v:         Produce a csv file
+    -des|cription: Include description from Bugzilla
+    -fr|om:        From revSpec
+    -l|ong:        Shorthand for -comments & -files
+    -t|o:          To revSpec (Default: @now)
+    -b|ranchpath:  Path to limit changes to
+    -[no]ht|ml:    Whether or not to produce html output
+
+Note that revSpecs are Perforce's way of handling changelist/label/dates. For
+more info see p4 help revisions. For your reference:
+
+  #rev    - A revision number or one of the following keywords:
+  #none   - A nonexistent revision (also #0).
+  #head   - The current head revision (also @now).
+  #have   - The revision on the current client.
+  @change - A change number: the revision as of that change.
+  @client - A client name: the revision on the client.
+  @label  - A label name: the revision in the label.
+  @date   - A date or date/time: the revision as of that time.
+            Either yyyy/mm/dd or yyyy/mm/dd:hh:mm:ss
+            Note that yyyy/mm/dd means yyyy/mm/dd:00:00:00.
+            To include all events on a day, specify the next day.
+
+=head1 DESCRIPTION
+
+This script produces release notes on the web or in a .csv file. You can also
+produce an .html file by using -html and redirecting stdout.
+
+=cut
+
+use FindBin;
+use Getopt::Long;
+use Pod::Usage;
+
+use P4;
+
+use lib "$FindBin::Bin/../../Web/common/lib";
+use lib "$FindBin::Bin/../../common/lib";
+use lib "$FindBin::Bin/../../lib";
+use lib "$FindBin::Bin/../lib";
+
+use Display;
+use DateUtils;
+use JIRAUtils;
+use Utils;
+
+#use webutils;
+
+# Globals
+my $VERSION  = '$Revision: #7 $';
+  ($VERSION) = ($VERSION =~ /\$Revision: (.*) /);
+
+my $p4;
+my @labels;
+my $headerPrinted;
+my $p4ticketsFile = '/opt/audience/perforce/p4tickets';
+
+my $bugsweb = 'http://bugs.audience.local/show_bug.cgi?id=';
+my $p4web   = 'http://p4web.audience.local:8080';
+my $jiraWeb = 'http://jira.audience.local/browse/';
+my %opts;
+
+my $changesCommand = '';
+
+local $| = 1;
+
+my $title    = 'Release Mission Control';
+my $subtitle = 'Select from and to revspecs to see the bugs changes between them';
+my $helpStr  = 'Both From and To are Perforce '
+             . i ('revSpecs')
+             . '. You can use changelists, labels, dates or clients. For more'
+             . ' see p4 help revisions or '
+             . a {
+                 href   => 'http://www.perforce.com/perforce/r12.2/manuals/cmdref/o.fspecs.html#1047453',
+                 target => 'rmcHelp',
+               },
+               'Perforce File Specifications',
+             .  '.'
+             . br
+             . b ('revSpec examples') 
+             . ': &lt;change&gt;, &lt;client&gt;, &lt;label&gt, '
+             . '&lt;date&gt; - yyyy/mm/dd or yyyy/mm/dd:hh:mm:ss'
+             . br
+             . b ('Note:')
+             . ' To show all changes after label1 but before label2 use >label1 for From and @label2 for To. Or specify To as now';
+my @columnHeadings = (
+  '#',
+  'Changelist',
+  'Bug ID',
+  'Issue',
+  'Type',
+  'Status',
+  'Fix Versions',
+  'User ID',
+  'Date',
+  '# of Files',
+  'Summary',
+  'Checkin Comments',
+  'Files',
+);
+
+sub displayForm (;$$$);
+
+sub footing (;$) {
+  my ($startTime) = @_;
+  
+  print '<center>', a { 
+    href => url (-relative => 1). "?csv=1&from=$opts{from}&to=$opts{to}&branchpath=$opts{branchpath}",
+  }, 'Export CSV</center>' if $opts{from} or $opts{to};
+
+  print end_form;
+  
+  my $script = $FindBin::Script =~ /index.pl/
+             ? 'rmc.pl'
+             : $FindBin::Script;
+
+  my ($sec, $min, $hour, $mday, $mon, $year) = 
+    localtime ((stat ($script))[9]);
+
+  $year += 1900;
+  $mon++;
+
+  my $dateModified   = "$mon/$mday/$year @ $hour:$min";
+  my $secondsElapsed = $startTime ? time () - $startTime . ' secs' : '';
+
+  print end_div;
+
+  print start_div {-class => 'copyright'};
+    print "$script version $VERSION: Last modified: $dateModified";
+    print " ($secondsElapsed)" if $secondsElapsed;
+    print br "Copyright &copy; $year, Audience - All rights reserved - Design by ClearSCM";
+  print end_div;
+
+  print end_html;
+
+  return;
+} # footing
+
+sub errorMsg ($;$) {
+  my ($msg, $exit) = @_;
+
+  unless ($opts{html}) {
+    error ($msg, $exit);
+    
+    return
+  } # if
+  
+  unless ($headerPrinted) {
+    print header;
+    print start_html;
+    
+    $headerPrinted =1;
+  } # unless
+
+  print font ({class => 'error'}, '<br>ERROR: ') . $msg;
+
+  if ($exit) {
+    footing;
+    exit $exit;
+  } # if
+} # errorMsg
+
+sub debugMsg ($) {
+  my ($msg) = @_;
+  
+  return unless $opts{debug};
+
+  unless ($opts{html}) {
+    debug $msg;
+    
+    return
+  } # if
+  
+  unless ($headerPrinted) {
+    print header;
+    print start_html;
+    
+    $headerPrinted = 1;
+  } # unless
+
+  print font ({class => 'error'}, '<br>DEBUG: ') . $msg;
+} # debugMsg
+
+sub formatTimestamp (;$) {
+  my ($time) = @_;
+  
+  my $date          = YMDHMS ($time);
+  my $formattedDate = substr ($date, 0, 4) . '-'
+                    . substr ($date, 4, 2) . '-'
+                    . substr ($date, 6, 2) . ' '
+                    . substr ($date, 9);
+  
+  return $formattedDate;
+} # formatTimestamp
+
+sub p4errors ($) {
+  my ($cmd) = @_;
+
+  my $msg  = "Unable to run \"p4 $cmd\"";
+     $msg .= $opts{html} ? '<br>' : "\n";
+
+  if ($p4->ErrorCount) {
+    displayForm $opts{from}, $opts{to}, $opts{branchpath};
+
+    errorMsg $msg . $p4->Errors, $p4->ErrorCount;
+  } # if
+
+  return;
+} # p4errors
+
+sub p4connect () {
+  $p4 = P4->new;
+
+  $p4->SetUser     ($opts{username});
+  $p4->SetClient   ($opts{client}) if $opts{client};
+  $p4->SetPort     ($opts{port});
+  
+  if ($opts{username} eq 'shared') {
+       $p4->SetTicketFile ($p4ticketsFile);
+  } else {
+    $p4->SetPassword ($opts{password});
+  } # if
+
+  verbose_nolf "Connecting to Perforce server $opts{port}...";
+  $p4->Connect or die "Unable to connect to Perforce Server\n";
+  verbose 'done';
+
+  unless ($opts{username} eq 'shared') {
+    verbose_nolf "Logging in as $opts{username}\@$opts{port}...";
+
+    $p4->RunLogin;
+
+    p4errors 'login';
+
+    verbose 'done';
+  } # unless
+
+  return $p4;
+} # p4connect
+
+sub getChanges ($;$$) {
+  my ($from, $to, $branchpath) = @_;
+
+  $from = "\@$from" unless $from =~ /^@/;
+  $to   = "\@$to"   unless $to   =~ /^@/;
+  
+  my $args;
+     #$args    = '-s shelved ';
+     $args   .= $branchpath if $branchpath;
+     $args   .= $from;
+     $args   .= ",$to" if $to;
+
+  my $cmd     = 'changes';
+  my $changes = $p4->Run ($cmd, $args);
+  
+  $changesCommand = "p4 $cmd $args" if $opts{debug};
+  
+  p4errors "$cmd $args";
+  
+  unless (@$changes) {
+    if ($to =~ /\@now/i) {
+      verbose "No changes since $from";
+    } else {
+      verbose "No changes between $from - $to";
+    } # if
+
+    return;
+  } else {
+    return @$changes;
+  } # unless
+} # getChanges
+
+sub getJobInfo ($) {
+  my ($job) = @_;
+
+  my $jobs = $p4->IterateJobs ("-e $job");
+
+  p4errors "jobs -e $job";
+
+  $job = $jobs->next if $jobs->hasNext;
+
+  return $job;
+} # getJobInfo
+
+sub getComments ($) {
+  my ($changelist) = @_;
+
+  my $change = $p4->FetchChange ($changelist);
+
+  p4errors "change $changelist";
+
+  return $change->{Description};
+} # getComments
+
+sub getFiles ($) {
+  my ($changelist) = @_;
+
+  my $files = $p4->Run ('files', "\@=$changelist");
+
+  p4errors "files \@=$changelist";
+
+  my @files;
+
+  push @files, $_->{depotFile} . '#' . $_->{rev} for @$files;
+
+  return @files;
+} # getFiles
+
+sub displayForm (;$$$) {
+  my ($from, $to, $branchpath) = @_;
+
+  $from //= '<today>';
+  $to   //= '<today>';
+
+  print p {align => 'center', class => 'dim'}, $helpStr;
+
+  print start_form {
+    method  => 'get',
+    actions => $FindBin::Script,
+  };
+
+  print start_table {
+    class       => 'table',
+    align       => 'center',
+    cellspacing => 1,
+    width       => '95%',
+  };
+
+  print Tr [th ['From', 'To']];
+  print start_Tr;
+  print td {align => 'center'}, textfield (
+    -name => 'from',
+    value => $from,
+    size  => 60,
+  );
+  print td {align => 'center'}, textfield (
+    -name => 'to',
+    value => $to,
+    size  => 60,
+  );
+  print end_Tr;
+  
+  print Tr [th {colspan => 2}, 'Branch/Path'];
+  print start_Tr;
+  print td {
+    colspan => 2,
+    align   => 'center',
+  }, textfield (
+    -name => 'branchpath',
+    value => $branchpath,
+    size  => 136,
+  );
+  print end_Tr;
+
+  print start_Tr;
+  print td {align => 'center', colspan => 2}, b ('Options:'), checkbox (
+    -name   => 'comments',
+    id      => 'comments',
+    onclick => 'toggleOption ("comments");',
+    label   => 'Comments',
+    checked => $opts{comments} ? 'checked' : '',
+    value   => $opts{comments},
+  ), checkbox (
+    -name   => 'files',
+    id      => 'files',
+    onclick => 'toggleOption ("files");',
+    label   => 'Files',
+    checked => $opts{files} ? 'checked' : '',
+    value   => $opts{files},
+#  ), checkbox (
+#    -name   => 'group',
+#    id      => 'group',
+#    onclick => 'groupIndicate();',
+#    label   => 'Group Indicate',
+#    checked => 'checked',
+#    value   => $opts{group},
+  );
+
+  print end_Tr;
+
+  print start_Tr;
+  print td {align => 'center', colspan => 2}, input {
+    type  => 'Submit',
+    value => 'Submit',
+  };
+  print end_Tr;
+  print end_table;
+  print p;
+
+  return;
+} # displayForm
+
+sub displayChangesHTML (@) {
+  my (@changes) = @_;
+
+  displayForm $opts{from}, $opts{to}, $opts{branchpath};
+
+  unless (@changes) {
+    my $msg  = "No changes found between $opts{from} and $opts{to}";
+       $msg .= " for $opts{branchpath}"; 
+    print p $msg;
+
+    return;
+  } # unless
+
+  my $displayComments = $opts{comments} ? '' : 'none';
+  my $displayFiles    = $opts{files}    ? '' : 'none';
+
+  debugMsg "Changes command used: $changesCommand";
+  
+  print start_table {
+    class => 'table-autosort',
+    align => 'center',
+    width => '95%',
+  };
+  
+  print start_thead;
+  print start_Tr;
+  print th '#';
+  print 
+  th {
+    class => 'table-sortable:numeric table-sortable',
+    title => 'Click to sort',
+  }, 'Changelist',
+  th {
+    class => 'table-sortable:numeric',
+    title => 'Click to sort',
+  }, 'Bug ID',
+  th {
+    class => 'table-sortable:numeric',
+    title => 'Click to sort',
+  }, 'Issue',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Type',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Status',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Fix Version',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'User ID',
+  th {
+    class => 'table-sortable:default table-sortable',
+    title => 'Click to sort',
+  }, 'Date',
+  th {
+    class => 'table-sortable:numeric table-sortable',
+    title => 'Click to sort',
+  }, '# of Files',
+  th 'Summary',
+  th {
+    id    => 'comments0',
+    style => "display: $displayComments",
+  }, 'Checkin Comments',
+  th {
+    id    => 'files0',
+    style => "display: $displayFiles",
+  }, 'Files';
+  print end_Tr;
+  print end_thead;
+  
+  my $i = 0;
+  
+  for (sort {$b->{change} <=> $a->{change}} @changes) {
+    my %change = %$_;
+
+    my @files = getFiles $change{change};
+    
+    my $job;
+    
+    for ($p4->Run ('fixes', "-c$change{change}")) { # Why am I uses fixes here?
+       $job = getJobInfo $_->{Job};
+       last; # FIXME - doesn't handle muliple jobs.
+    } # for
+
+    $job->{Description} = font {color => '#aaa'}, 'N/A' unless $job;
+
+    my ($bugid, $jiraIssue);
+    
+    if ($job->{Job}) {
+      if ($job->{Job} =~ /^(\d+)/) {
+        $bugid = $1;
+      } elsif ($job->{Job} =~ /^(\w+-\d+)/) {
+        $jiraIssue = $1;
+      }
+    } # if
+
+    # Using the following does not guarantee the ordering of the elements 
+    # emitted!
+    #
+    # my $buglink  = a {
+    #   href   => "$bugsweb$bugid",
+    #   target => 'bugzilla',
+    # }, $bugid;
+    #
+    # IOW sometimes I get <a href="..." target="bugzilla"> and other times I 
+    # get <a target="bugzilla" href="...">! Not cool because the JavaScript
+    # later on is comparing innerHTML and this messes that up. So we write this
+    # out by hand instead.
+    my $buglink     = $bugid 
+                    ? "<a href=\"$bugsweb$bugid\" target=\"bugzilla\">$bugid</a>"
+                    : font {color => '#aaa'}, 'N/A';
+    my $jiralink    = $jiraIssue
+                    ? "<a href=\"$jiraWeb$jiraIssue\" target=\"jira\">$jiraIssue</a>"
+                    : font {color => '#aaa'}, 'N/A';                  
+    my $cllink      = "<a href=\"$p4web/$_->{change}?ac=133\" target=\"p4web\">$_->{change}</a>";
+    my $userid      = $_->{user};
+    my $description = $job->{Description};
+    my $jiraStatus  = font {color => '#aaa'}, 'N/A';
+    my $issueType   = font {color => '#aaa'}, 'N/A';
+    my $fixVersions = font {color => '#aaa'}, 'N/A';
+    
+    if ($jiraIssue) {
+      my $issue;
+      
+      eval {$issue = getIssue ($jiraIssue, qw (status issuetype fixVersions))};
+      
+      unless ($@) {
+        $jiraStatus = $issue->{fields}{status}{name};
+        $issueType  = $issue->{fields}{issuetype}{name};
+        
+        my @fixVersions;
+        
+        push @fixVersions, $_->{name} for @{$issue->{fields}{fixVersions}};
+          
+        $fixVersions = join '<br>', @fixVersions; 
+      } # unless
+    } # if
+    
+    print start_Tr {id => ++$i};
+        
+    # Attempting to "right size" the columns...
+    print 
+      td {width => '10px',  align => 'center'},                       $i,
+      td {width => '15px',  align => 'center', id => "changelist$i"}, $cllink,
+      td {width => '60px',  align => 'center', id => "bugzilla$i"},   $buglink,
+      td {width => '80px',  align => 'center', id => "jira$i"},       $jiralink,
+      td {width => '50px',  align => 'center', id => "type$i"},       $issueType,
+      td {width => '50px',  align => 'center', id => "jirastatus$i"}, $jiraStatus,
+      td {width => '50px',  align => 'center', id => "fixVersion$i"}, $fixVersions,
+      td {width => '30px',  align => 'center', id => "userid$i"},     a {href => "mailto:$userid\@audience.com" }, $userid,
+      td {width => '130px', align => 'center'},                       formatTimestamp ($_->{time}),
+      td {width => '10px',  align => 'center'},                       scalar @files;
+      
+    if ($description =~ /N\/A/) {
+      print td {id => "description$i", align => 'center'}, $description;
+    } else {
+      print td {id => "description$i"}, $description;
+    } # if
+      
+    print
+      td {id     => "comments$i",
+          valign => 'top',
+          style  => "display: $displayComments",
+      }, pre {class => 'code'}, getComments ($_->{change});
+
+    print start_td {
+      id      => "files$i",
+      valign  => 'top',
+      style   => "display: $displayFiles"
+    };
+
+    print start_pre {class => 'code'};
+
+    for my $file (@files) {
+      my ($filelink) = ($file =~ /(.*)\#/);
+      my ($revision) = ($file =~ /\#(\d+)/);
+      
+      # Note: For a Perforce "Add to Source Control" operation, revision is 
+      # actually "none". Let's fix this.
+      $revision = 1 unless $revision;
+      
+      if ($revision == 1) {
+        print a {
+          href   => '#',
+        }, img {
+          src   => "$p4web/rundiffprevsmallIcon?ac=20",
+          title => "There's nothing to diff since this is the first revision",
+        };
+      } else {
+        print a {
+          href   => "$p4web$filelink?ac=19&rev1=$revision&rev2=" . ($revision - 1),
+          target => 'p4web',
+        }, img {
+          src    => "$p4web/rundiffprevsmallIcon?ac=20",
+          title  => "Diff rev #$revision vs. rev #" . ($revision -1),
+        };
+      } # if
+      
+      print a {
+        href   => "$p4web$filelink?ac=22",
+        target => 'p4web',
+      }, "$file<br>";
+    } # for
+
+    print end_pre;
+    print end_td;
+
+    print end_Tr;
+  } # for
+
+  print end_table;
+
+  return;
+} # displayChangesHTML
+
+sub displayChange (\%;$) {
+  my ($change, $nbr) = @_;
+  
+  $nbr //= 1;
+  
+  # Note: $change must about -c!
+  my $args  = "-c$change->{change}";
+  my $cmd   = 'fixes';
+  my $fix = $p4->Run ($cmd, $args);
+
+  p4errors "$cmd $args";
+
+  errorMsg "Change $change->{change} is associated with multiple jobs. This case is not handled yet" if @$fix > 1;
+
+  $fix = $$fix[0];
+  
+  # If there was no fix associated with this change we will use the change data.
+  unless ($fix) {
+    $fix->{Change} = $change->{change}; 
+    $fix->{User}   = $change->{user};
+    $fix->{Date}   = $change->{time};
+  } # unless
+  
+  my $job;
+  $job = getJobInfo ($fix->{Job}) if $fix->{Job};
+  
+  unless ($job) {
+    chomp $change->{desc};
+
+    $job = {
+      Description => $change->{desc},
+      Job         => 'Unknown',
+    };
+  } # unless
+
+  my ($bugid)  = ($job->{Job} =~ /^(\d+)/);
+  
+  chomp $job->{Description};
+  
+  my $description  = "$change->{change}";
+     $description .= "/$bugid" if $bugid;
+     $description .= ": $job->{Description} ($fix->{User} ";
+     $description .= ymdhms ($fix->{Date}) . ')';
+
+  display $nbr++ . ") $description";
+
+  if ($opts{comments}) {
+    print "\n";
+    print "Comments:\n" . '-'x80 . "\n" . getComments ($fix->{Change}) . "\n";
+  } # if
+
+  if ($opts{description}) {
+    display '';
+    display "Description:\n" . '-'x80 . "\n" . $job->{Description};
+  } # if
+
+  if ($opts{files}) {
+    display "Files:\n" . '-'x80;
+
+    for (getFiles $fix->{Change}) {
+      display "\t$_";
+    } # for
+
+    display '';
+  } # if
+
+  return $nbr;
+} # displayChangesHTML
+
+sub displayChanges (@) {
+  my (@changes) = @_;
+
+  unless (@changes) {
+    my $msg  = "No changes found between $opts{from} and $opts{to}";
+       $msg .= " for $opts{branchpath}"; 
+    print p $msg;
+
+    return;
+  } # unless
+
+  my $i;
+  
+  debugMsg "Changes command used: $changesCommand";
+  
+  $i = displayChange %$_, $i for @changes;
+    
+  return;
+} # displayChanges
+
+sub heading ($;$) {
+  my ($title, $subtitle) = @_;
+
+  print header unless $headerPrinted;
+
+  $headerPrinted = 1;
+
+  print start_html {
+    title   => $title,
+    head    => Link ({
+      rel   => 'shortcut icon',
+      href  => 'http://p4web.audience.local:8080/favicon.ico',
+      type  => 'image/x-icon',
+    }),
+    author  => 'Andrew DeFaria <Andrew@ClearSCM.com>',
+    script  => [{
+      language => 'JavaScript',
+      src      => 'rmc.js',
+    }, {
+      language => 'JavaScript',
+      src      => 'rmctable.js',
+    }],
+    style      => ['rmc.css'],
+    onload     => 'setOptions();',
+  }, $title;
+  
+  print h1 {class => 'title'}, "<center><img src=\"Audience.png\"> $title</center>";
+  print h3 "<center><font color='#838'>$subtitle</font></center>" if $subtitle;
+
+  return;
+} # heading
+
+sub exportCSV ($@) {
+  my ($filename, @data) = @_;
+
+  print header (
+    -type       => 'application/octect-stream',
+    -attachment => $filename,
+  );
+
+  # Print heading line
+  my $columns;
+  
+  # Note that we do not include the '#' column so start at 1
+  for (my $i = 1; $i < @columnHeadings; $i++) {
+    $columns .= "\"$columnHeadings[$i]\"";
+    
+    $columns .= ',' unless $i == @columnHeadings;
+  } # for
+  
+  print "$columns\n";
+  
+  for (sort {$b->{change} <=> $a->{change}} @data) {
+    my %change = %$_;
+
+    ## TODO: This code is duplicated (See displayChange). Consider refactoring.
+    # Note: $change must be right next to the -c!
+    my (%job, $jiraStatus, $issueType, $fixVersions);
+    
+    for ($p4->Run ('fixes', "-c$change{change}")) {
+       %job = %{getJobInfo $_->{Job}};
+       last; # FIXME - doesn't handle muliple jobs.
+    } # for
+
+    $job{Description} = '' unless %job;
+
+    my ($bugid, $jiraIssue);
+    
+    if ($job{Job}) {
+      if ($job{Job} =~ /^(\d+)/) {
+        $bugid = $1;
+      } elsif ($job{Job} =~ /^(\w+-\d+)/) {
+        $jiraIssue = $1;
+      }
+    } # if    
+  
+    if ($jiraIssue) {
+      my $issue;
+      
+      eval {$issue = getIssue ($jiraIssue, qw (status issuetype fixVersions))};
+      
+      unless ($@) {
+        $jiraStatus = $issue->{fields}{status}{name};
+        $issueType  = $issue->{fields}{issuetype}{name};
+
+        my @fixVersions;
+        
+        push @fixVersions, $_->{name} for @{$issue->{fields}{fixVersions}};
+          
+        $fixVersions = join "\n", @fixVersions; 
+      } # unless
+    } # if
+
+    my $job;
+    
+    unless ($job) {
+      chomp $change{desc};
+  
+      $job = {
+        Description => $change{desc},
+        Job         => 'Unknown',
+      };
+    } # unless
+    ## End of refactor code
+
+    $job{Description} = join ("\r\n", split "\n", $job{Description});
+    
+    my @files    = getFiles $change{change};
+    my $comments = join ("\r\n", split "\n", getComments ($change{change}));
+    
+    # Fix up double quotes in description and comments
+    $job{Description} =~ s/"/""/g;
+    $comments         =~ s/"/""/g;
+    
+    print "$change{change},";
+    print $bugid       ? "$bugid,"       : ',';
+    print $jiraIssue   ? "$jiraIssue,"   : ',';
+    print $issueType   ? "$issueType,"   : ',';
+    print $jiraStatus  ? "$jiraStatus,"  : ',';
+    print $fixVersions ? "$fixVersions," : ',';
+    print "$change{user},";
+    print '"' . formatTimestamp ($change{time}) . '",';
+    print scalar @files . ',';
+    print "\"$job{Description}\",";
+    print "\"$comments\",";
+    print '"' . join ("\n", @files) . "\"";
+    print "\n";
+  } # for
+  
+  return;
+} # exportCSV
+
+sub main {
+  # Standard opts
+  $opts{usage}    = sub { pod2usage };
+  $opts{help}     = sub { pod2usage (-verbose => 2)};
+
+  # Opts for this script
+  $opts{username}   //= $ENV{P4USER}         || $ENV{USERNAME}    || $ENV{USER};
+  $opts{client}     //= $ENV{P4CLIENT};
+  $opts{port}       //= $ENV{P4PORT}         || 'perforce:1666';
+  $opts{password}   //= $ENV{P4PASSWD};
+  $opts{html}       //= $ENV{HTML}           || param ('html')    || 1;
+  $opts{html}         = (-t) ? 0 : 1;
+  $opts{debug}      //= $ENV{DEBUG}          || param ('debug')   || sub { set_debug };
+  $opts{verbose}    //= $ENV{VERBOSE}        || param ('verbose') || sub { set_verbose };
+  $opts{jiraserver} //= 'jira';
+  $opts{from}         = param 'from';
+  $opts{to}           = param 'to';
+  $opts{branchpath}   = param ('branchpath') || '//AudEngr/Import/VSS/...';
+  $opts{group}        = param 'group';
+  $opts{comments}   //= $ENV{COMMENTS}       || param 'comments';
+  $opts{files}      //= $ENV{FILES}          || param 'files';
+  $opts{long}       //= $ENV{LONG}           || param 'long';
+  $opts{csv}        //= $ENV{CSV}            || param 'csv';
+  
+  GetOptions (
+    \%opts,
+    'verbose',
+    'debug',
+    'help',
+    'usage',
+    'port=s',
+    'username=s',
+    'password=s',
+    'client=s',
+    'comments',
+    'files',
+    'description',
+    'long',
+    'from=s',
+    'to=s',
+    'branchpath=s',
+    'html!',
+    'csv',
+  ) || pod2usage;
+
+  $opts{comments} = $opts{files} = 1 if $opts{long};
+  $opts{debug}    = get_debug        if ref $opts{debug}   eq 'CODE';
+  $opts{verbose}  = get_verbose      if ref $opts{verbose} eq 'CODE';
+
+  # Are we doing HTML?
+  if ($opts{html}) {
+    require CGI::Carp;
+
+    CGI::Carp->import ('fatalsToBrowser');
+
+    $opts{username} ||= 'shared';
+  } # if
+
+  # Needed if using the shared user
+  if ($opts{username} eq 'shared') {
+    unless (-f $p4ticketsFile) {
+      errorMsg "Using 'shared' user but there is no P4TICKETS file ($p4ticketsFile)", 1;
+    } # unless
+  } else {
+    if ($opts{username} and not $opts{password}) {
+      $opts{password} = GetPassword "I need the Perforce password for $opts{username}";
+    } # if
+  } # if
+
+  p4connect;
+  
+  my $jira = Connect2JIRA (undef, undef, $opts{jiraserver});
+
+  unless ($opts{from} or $opts{to}) {
+    if ($opts{html}) {
+      heading $title, $subtitle;
+
+      displayForm $opts{from}, $opts{to}, $opts{branchpath};
+
+      footing;
+
+      exit;
+    } # if
+  } # unless
+  
+  my $ymd = YMD;
+  my $midnight = substr ($ymd, 0, 4) . '/'
+               . substr ($ymd, 4, 2) . '/'
+               . substr ($ymd, 6, 2) . ':00:00:00';
+
+  $opts{to}   //= 'now';
+  $opts{from} //= $midnight;
+
+  $opts{to}     = 'now'     if ($opts{to}   eq '<today>' or $opts{to}   eq '');
+  $opts{from}   = $midnight if ($opts{from} eq '<today>' or $opts{from} eq '');
+
+  my $msg = 'Changes made ';
+  
+  if ($opts{to} =~ /^now$/i and $opts{from} eq $midnight) {
+    $msg .= 'today';
+  } elsif ($opts{to} =~ /^now$/i) {
+    $msg .= "since $opts{from}";
+  } else {
+    $msg .= "between $opts{from} and $opts{to}";
+  } # if
+
+  if ($opts{csv}) {
+    my $filename = "$opts{from}_$opts{to}.csv";
+    
+    $filename =~ s/\//-/g;
+    $filename =~ s/\@//g;
+    
+    debug "branchpath = $opts{branchpath}";
+    
+    exportCSV $filename, getChanges $opts{from}, $opts{to}, $opts{branchpath};
+    
+    return;
+  } # if
+
+  if ($opts{html}) {
+    heading 'Release Mission Control', $msg;
+  } else {
+    display "$msg\n";
+  } # if
+
+  if ($opts{html}) {
+    my $startTime = time;
+    displayChangesHTML getChanges $opts{from}, $opts{to}, $opts{branchpath};  
+    
+    footing $startTime;
+  } else {
+    displayChanges getChanges $opts{from}, $opts{to}, $opts{branchpath};
+  } # if
+
+  return;
+} # main
+
+main;
+
+exit;
diff --git a/rmc/rmctable.js b/rmc/rmctable.js
new file mode 100644 (file)
index 0000000..778ccb1
--- /dev/null
@@ -0,0 +1,1002 @@
+/**
+ * Copyright (c)2005-2009 Matt Kruse (javascripttoolbox.com)
+ * 
+ * Dual licensed under the MIT and GPL licenses. 
+ * This basically means you can use this code however you want for
+ * free, but don't claim to have written it yourself!
+ * Donations always accepted: http://www.JavascriptToolbox.com/donate/
+ * 
+ * Please do not link to the .js files on javascripttoolbox.com from
+ * your site. Copy the files locally to your server instead.
+ * 
+ */
+/**
+ * Table.js
+ * Functions for interactive Tables
+ *
+ * Copyright (c) 2007 Matt Kruse (javascripttoolbox.com)
+ * Dual licensed under the MIT and GPL licenses. 
+ *
+ * @version 0.981
+ *
+ * @history 0.981 2007-03-19 Added Sort.numeric_comma, additional date parsing formats
+ * @history 0.980 2007-03-18 Release new BETA release pending some testing. Todo: Additional docs, examples, plus jQuery plugin.
+ * @history 0.959 2007-03-05 Added more "auto" functionality, couple bug fixes
+ * @history 0.958 2007-02-28 Added auto functionality based on class names
+ * @history 0.957 2007-02-21 Speed increases, more code cleanup, added Auto Sort functionality
+ * @history 0.956 2007-02-16 Cleaned up the code and added Auto Filter functionality.
+ * @history 0.950 2006-11-15 First BETA release.
+ *
+ * @todo Add more date format parsers
+ * @todo Add style classes to colgroup tags after sorting/filtering in case the user wants to highlight the whole column
+ * @todo Correct for colspans in data rows (this may slow it down)
+ * @todo Fix for IE losing form control values after sort?
+ */
+
+/**
+ * Sort Functions
+ */
+var Sort = (function(){
+  var sort = {};
+  // Default alpha-numeric sort
+  // --------------------------
+  sort.alphanumeric = function(a,b) {
+    return (a==b)?0:(a<b)?-1:1;
+  };
+  sort['default'] = sort.alphanumeric; // IE chokes on sort.default
+
+  // This conversion is generalized to work for either a decimal separator of , or .
+  sort.numeric_converter = function(separator) {
+    return function(val) {
+      if (typeof(val)=="string") {
+        val = parseFloat(val.replace(/^[^\d\.]*([\d., ]+).*/g,"$1").replace(new RegExp("[^\\\d"+separator+"]","g"),'').replace(/,/,'.')) || 0;
+      }
+      return val || 0;
+    };
+  };
+
+  // Numeric Sort  
+  // ------------
+  sort.numeric = function(a,b) {
+    return sort.numeric.convert(a)-sort.numeric.convert(b);
+  };
+  sort.numeric.convert = sort.numeric_converter(".");
+
+  // Numeric Sort  - comma decimal separator
+  // --------------------------------------
+  sort.numeric_comma = function(a,b) {
+    return sort.numeric_comma.convert(a)-sort.numeric_comma.convert(b);
+  };
+  sort.numeric_comma.convert = sort.numeric_converter(",");
+
+  // Case-insensitive Sort
+  // ---------------------
+  sort.ignorecase = function(a,b) {
+    return sort.alphanumeric(sort.ignorecase.convert(a),sort.ignorecase.convert(b));
+  };
+  sort.ignorecase.convert = function(val) {
+    if (val==null) { return ""; }
+    return (""+val).toLowerCase();
+  };
+
+  // Currency Sort
+  // -------------
+  sort.currency = sort.numeric; // Just treat it as numeric!
+  sort.currency_comma = sort.numeric_comma;
+
+  // Date sort
+  // ---------
+  sort.date = function(a,b) {
+    return sort.numeric(sort.date.convert(a),sort.date.convert(b));
+  };
+  // Convert 2-digit years to 4
+  sort.date.fixYear=function(yr) {
+    yr = +yr;
+    if (yr<50) { yr += 2000; }
+    else if (yr<100) { yr += 1900; }
+    return yr;
+  };
+  sort.date.formats = [
+    // YY[YY]-MM-DD
+    { re:/(\d{2,4})-(\d{1,2})-(\d{1,2})/ , f:function(x){ return (new Date(sort.date.fixYear(x[1]),+x[2],+x[3])).getTime(); } }
+    // MM/DD/YY[YY] or MM-DD-YY[YY]
+    ,{ re:/(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})/ , f:function(x){ return (new Date(sort.date.fixYear(x[3]),+x[1],+x[2])).getTime(); } }
+    // Any catch-all format that new Date() can handle. This is not reliable except for long formats, for example: 31 Jan 2000 01:23:45 GMT
+    ,{ re:/(.*\d{4}.*\d+:\d+\d+.*)/, f:function(x){ var d=new Date(x[1]); if(d){return d.getTime();} } }
+  ];
+  sort.date.convert = function(val) {
+    var m,v, f = sort.date.formats;
+    for (var i=0,L=f.length; i<L; i++) {
+      if (m=val.match(f[i].re)) {
+        v=f[i].f(m);
+        if (typeof(v)!="undefined") { return v; }
+      }
+    }
+    return 9999999999999; // So non-parsed dates will be last, not first
+  };
+
+  return sort;
+})();
+
+/**
+ * The main Table namespace
+ */
+var Table = (function(){
+
+  /**
+   * Determine if a reference is defined
+   */
+  function def(o) {return (typeof o!="undefined");};
+
+  /**
+   * Determine if an object or class string contains a given class.
+   */
+  function hasClass(o,name) {
+    return new RegExp("(^|\\s)"+name+"(\\s|$)").test(o.className);
+  };
+
+  /**
+   * Add a class to an object
+   */
+  function addClass(o,name) {
+    var c = o.className || "";
+    if (def(c) && !hasClass(o,name)) {
+      o.className += (c?" ":"") + name;
+    }
+  };
+
+  /**
+   * Remove a class from an object
+   */
+  function removeClass(o,name) {
+    var c = o.className || "";
+    o.className = c.replace(new RegExp("(^|\\s)"+name+"(\\s|$)"),"$1");
+  };
+
+  /**
+   * For classes that match a given substring, return the rest
+   */
+  function classValue(o,prefix) {
+    var c = o.className;
+    if (c.match(new RegExp("(^|\\s)"+prefix+"([^ ]+)"))) {
+      return RegExp.$2;
+    }
+    return null;
+  };
+
+  /**
+   * Return true if an object is hidden.
+   * This uses the "russian doll" technique to unwrap itself to the most efficient
+   * function after the first pass. This avoids repeated feature detection that 
+   * would always fall into the same block of code.
+   */
+   function isHidden(o) {
+    if (window.getComputedStyle) {
+      var cs = window.getComputedStyle;
+      return (isHidden = function(o) {
+        return 'none'==cs(o,null).getPropertyValue('display');
+      })(o);
+    }
+    else if (window.currentStyle) {
+      return(isHidden = function(o) {
+        return 'none'==o.currentStyle['display'];
+      })(o);
+    }
+    return (isHidden = function(o) {
+      return 'none'==o.style['display'];
+    })(o);
+  };
+
+  /**
+   * Get a parent element by tag name, or the original element if it is of the tag type
+   */
+  function getParent(o,a,b) {
+    if (o!=null && o.nodeName) {
+      if (o.nodeName==a || (b && o.nodeName==b)) {
+        return o;
+      }
+      while (o=o.parentNode) {
+        if (o.nodeName && (o.nodeName==a || (b && o.nodeName==b))) {
+          return o;
+        }
+      }
+    }
+    return null;
+  };
+
+  /**
+   * Utility function to copy properties from one object to another
+   */
+  function copy(o1,o2) {
+    for (var i=2;i<arguments.length; i++) {
+      var a = arguments[i];
+      if (def(o1[a])) {
+        o2[a] = o1[a];
+      }
+    }
+  }
+
+  // The table object itself
+  var table = {
+    //Class names used in the code
+    AutoStripeClassName:"table-autostripe",
+    StripeClassNamePrefix:"table-stripeclass:",
+
+    AutoSortClassName:"table-autosort",
+    AutoSortColumnPrefix:"table-autosort:",
+    AutoSortTitle:"Click to sort",
+    SortedAscendingClassName:"table-sorted-asc",
+    SortedDescendingClassName:"table-sorted-desc",
+    SortableClassName:"table-sortable",
+    SortableColumnPrefix:"table-sortable:",
+    NoSortClassName:"table-nosort",
+
+    AutoFilterClassName:"table-autofilter",
+    FilteredClassName:"table-filtered",
+    FilterableClassName:"table-filterable",
+    FilteredRowcountPrefix:"table-filtered-rowcount:",
+    RowcountPrefix:"table-rowcount:",
+    FilterAllLabel:"Filter: All",
+
+    AutoPageSizePrefix:"table-autopage:",
+    AutoPageJumpPrefix:"table-page:",
+    PageNumberPrefix:"table-page-number:",
+    PageCountPrefix:"table-page-count:"
+  };
+
+  /**
+   * A place to store misc table information, rather than in the table objects themselves
+   */
+  table.tabledata = {};
+
+  /**
+   * Resolve a table given an element reference, and make sure it has a unique ID
+   */
+  table.uniqueId=1;
+  table.resolve = function(o,args) {
+    if (o!=null && o.nodeName && o.nodeName!="TABLE") {
+      o = getParent(o,"TABLE");
+    }
+    if (o==null) { return null; }
+    if (!o.id) {
+      var id = null;
+      do { var id = "TABLE_"+(table.uniqueId++); } 
+        while (document.getElementById(id)!=null);
+      o.id = id;
+    }
+    this.tabledata[o.id] = this.tabledata[o.id] || {};
+    if (args) {
+      copy(args,this.tabledata[o.id],"stripeclass","ignorehiddenrows","useinnertext","sorttype","col","desc","page","pagesize");
+    }
+    return o;
+  };
+
+
+  /**
+   * Run a function against each cell in a table header or footer, usually 
+   * to add or remove css classes based on sorting, filtering, etc.
+   */
+  table.processTableCells = function(t, type, func, arg) {
+    t = this.resolve(t);
+    if (t==null) { return; }
+    if (type!="TFOOT") {
+      this.processCells(t.tHead, func, arg);
+    }
+    if (type!="THEAD") {
+      this.processCells(t.tFoot, func, arg);
+    }
+  };
+
+  /**
+   * Internal method used to process an arbitrary collection of cells.
+   * Referenced by processTableCells.
+   * It's done this way to avoid getElementsByTagName() which would also return nested table cells.
+   */
+  table.processCells = function(section,func,arg) {
+    if (section!=null) {
+      if (section.rows && section.rows.length && section.rows.length>0) { 
+        var rows = section.rows;
+        for (var j=0,L2=rows.length; j<L2; j++) { 
+          var row = rows[j];
+          if (row.cells && row.cells.length && row.cells.length>0) {
+            var cells = row.cells;
+            for (var k=0,L3=cells.length; k<L3; k++) {
+              var cellsK = cells[k];
+              func.call(this,cellsK,arg);
+            }
+          }
+        }
+      }
+    }
+  };
+
+  /**
+   * Get the cellIndex value for a cell. This is only needed because of a Safari
+   * bug that causes cellIndex to exist but always be 0.
+   * Rather than feature-detecting each time it is called, the function will
+   * re-write itself the first time it is called.
+   */
+  table.getCellIndex = function(td) {
+    var tr = td.parentNode;
+    var cells = tr.cells;
+    if (cells && cells.length) {
+      if (cells.length>1 && cells[cells.length-1].cellIndex>0) {
+        // Define the new function, overwrite the one we're running now, and then run the new one
+        (this.getCellIndex = function(td) {
+          return td.cellIndex;
+        })(td);
+      }
+      // Safari will always go through this slower block every time. Oh well.
+      for (var i=0,L=cells.length; i<L; i++) {
+        if (tr.cells[i]==td) {
+          return i;
+        }
+      }
+    }
+    return 0;
+  };
+
+  /**
+   * A map of node names and how to convert them into their "value" for sorting, filtering, etc.
+   * These are put here so it is extensible.
+   */
+  table.nodeValue = {
+    'INPUT':function(node) { 
+      if (def(node.value) && node.type && ((node.type!="checkbox" && node.type!="radio") || node.checked)) {
+        return node.value;
+      }
+      return "";
+    },
+    'SELECT':function(node) {
+      if (node.selectedIndex>=0 && node.options) {
+        // Sort select elements by the visible text
+        return node.options[node.selectedIndex].text;
+      }
+      return "";
+    },
+    'IMG':function(node) {
+      return node.name || "";
+    }
+  };
+
+  /**
+   * Get the text value of a cell. Only use innerText if explicitly told to, because 
+   * otherwise we want to be able to handle sorting on inputs and other types
+   */
+  table.getCellValue = function(td,useInnerText) {
+    if (useInnerText && def(td.innerText)) {
+      return td.innerText;
+    }
+    if (!td.childNodes) { 
+      return ""; 
+    }
+    var childNodes=td.childNodes;
+    var ret = "";
+    for (var i=0,L=childNodes.length; i<L; i++) {
+      var node = childNodes[i];
+      var type = node.nodeType;
+      // In order to get realistic sort results, we need to treat some elements in a special way.
+      // These behaviors are defined in the nodeValue() object, keyed by node name
+      if (type==1) {
+        var nname = node.nodeName;
+        if (this.nodeValue[nname]) {
+          ret += this.nodeValue[nname](node);
+        }
+        else {
+          ret += this.getCellValue(node);
+        }
+      }
+      else if (type==3) {
+        if (def(node.innerText)) {
+          ret += node.innerText;
+        }
+        else if (def(node.nodeValue)) {
+          ret += node.nodeValue;
+        }
+      }
+    }
+    return ret;
+  };
+
+  /**
+   * Consider colspan and rowspan values in table header cells to calculate the actual cellIndex
+   * of a given cell. This is necessary because if the first cell in row 0 has a rowspan of 2, 
+   * then the first cell in row 1 will have a cellIndex of 0 rather than 1, even though it really
+   * starts in the second column rather than the first.
+   * See: http://www.javascripttoolbox.com/temp/table_cellindex.html
+   */
+  table.tableHeaderIndexes = {};
+  table.getActualCellIndex = function(tableCellObj) {
+    if (!def(tableCellObj.cellIndex)) { return null; }
+    var tableObj = getParent(tableCellObj,"TABLE");
+    var cellCoordinates = tableCellObj.parentNode.rowIndex+"-"+this.getCellIndex(tableCellObj);
+
+    // If it has already been computed, return the answer from the lookup table
+    if (def(this.tableHeaderIndexes[tableObj.id])) {
+      return this.tableHeaderIndexes[tableObj.id][cellCoordinates];      
+    } 
+
+    var matrix = [];
+    this.tableHeaderIndexes[tableObj.id] = {};
+    var thead = getParent(tableCellObj,"THEAD");
+    var trs = thead.getElementsByTagName('TR');
+
+    // Loop thru every tr and every cell in the tr, building up a 2-d array "grid" that gets
+    // populated with an "x" for each space that a cell takes up. If the first cell is colspan
+    // 2, it will fill in values [0] and [1] in the first array, so that the second cell will
+    // find the first empty cell in the first row (which will be [2]) and know that this is
+    // where it sits, rather than its internal .cellIndex value of [1].
+    for (var i=0; i<trs.length; i++) {
+      var cells = trs[i].cells;
+      for (var j=0; j<cells.length; j++) {
+        var c = cells[j];
+        var rowIndex = c.parentNode.rowIndex;
+        var cellId = rowIndex+"-"+this.getCellIndex(c);
+        var rowSpan = c.rowSpan || 1;
+        var colSpan = c.colSpan || 1;
+        var firstAvailCol;
+        if(!def(matrix[rowIndex])) { 
+          matrix[rowIndex] = []; 
+        }
+        var m = matrix[rowIndex];
+        // Find first available column in the first row
+        for (var k=0; k<m.length+1; k++) {
+          if (!def(m[k])) {
+            firstAvailCol = k;
+            break;
+          }
+        }
+        this.tableHeaderIndexes[tableObj.id][cellId] = firstAvailCol;
+        for (var k=rowIndex; k<rowIndex+rowSpan; k++) {
+          if(!def(matrix[k])) { 
+            matrix[k] = []; 
+          }
+          var matrixrow = matrix[k];
+          for (var l=firstAvailCol; l<firstAvailCol+colSpan; l++) {
+            matrixrow[l] = "x";
+          }
+        }
+      }
+    }
+    // Store the map so future lookups are fast.
+    return this.tableHeaderIndexes[tableObj.id][cellCoordinates];
+  };
+
+  /**
+   * Sort all rows in each TBODY (tbodies are sorted independent of each other)
+   */
+  table.sort = function(o,args) {
+    var t, tdata, sortconvert=null;
+    // Allow for a simple passing of sort type as second parameter
+    if (typeof(args)=="function") {
+      args={sorttype:args};
+    }
+    args = args || {};
+
+    // If no col is specified, deduce it from the object sent in
+    if (!def(args.col)) { 
+      args.col = this.getActualCellIndex(o) || 0; 
+    }
+    // If no sort type is specified, default to the default sort
+    args.sorttype = args.sorttype || Sort['default'];
+
+    // Resolve the table
+    t = this.resolve(o,args);
+    tdata = this.tabledata[t.id];
+
+    // If we are sorting on the same column as last time, flip the sort direction
+    if (def(tdata.lastcol) && tdata.lastcol==tdata.col && def(tdata.lastdesc)) {
+      tdata.desc = !tdata.lastdesc;
+    }
+    else {
+      tdata.desc = !!args.desc;
+    }
+
+    // Store the last sorted column so clicking again will reverse the sort order
+    tdata.lastcol=tdata.col;
+    tdata.lastdesc=!!tdata.desc;
+
+    // If a sort conversion function exists, pre-convert cell values and then use a plain alphanumeric sort
+    var sorttype = tdata.sorttype;
+    if (typeof(sorttype.convert)=="function") {
+      sortconvert=tdata.sorttype.convert;
+      sorttype=Sort.alphanumeric;
+    }
+
+    // Loop through all THEADs and remove sorted class names, then re-add them for the col
+    // that is being sorted
+    this.processTableCells(t,"THEAD",
+      function(cell) {
+        if (hasClass(cell,this.SortableClassName)) {
+          removeClass(cell,this.SortedAscendingClassName);
+          removeClass(cell,this.SortedDescendingClassName);
+          // If the computed colIndex of the cell equals the sorted colIndex, flag it as sorted
+          if (tdata.col==table.getActualCellIndex(cell) && (classValue(cell,table.SortableClassName))) {
+            addClass(cell,tdata.desc?this.SortedAscendingClassName:this.SortedDescendingClassName);
+          }
+        }
+      }
+    );
+
+    // Sort each tbody independently
+    var bodies = t.tBodies;
+    if (bodies==null || bodies.length==0) { return; }
+
+    // Define a new sort function to be called to consider descending or not
+    var newSortFunc = (tdata.desc)?
+      function(a,b){return sorttype(b[0],a[0]);}
+      :function(a,b){return sorttype(a[0],b[0]);};
+
+    var useinnertext=!!tdata.useinnertext;
+    var col = tdata.col;
+
+    for (var i=0,L=bodies.length; i<L; i++) {
+      var tb = bodies[i], tbrows = tb.rows, rows = [];
+
+      // Allow tbodies to request that they not be sorted
+      if(!hasClass(tb,table.NoSortClassName)) {
+        // Create a separate array which will store the converted values and refs to the
+        // actual rows. This is the array that will be sorted.
+        var cRow, cRowIndex=0;
+        if (cRow=tbrows[cRowIndex]){
+          // Funky loop style because it's considerably faster in IE
+          do {
+            if (rowCells = cRow.cells) {
+              var cellValue = (col<rowCells.length)?this.getCellValue(rowCells[col],useinnertext):null;
+              if (sortconvert) cellValue = sortconvert(cellValue);
+              rows[cRowIndex] = [cellValue,tbrows[cRowIndex]];
+            }
+          } while (cRow=tbrows[++cRowIndex])
+        }
+
+        // Do the actual sorting
+        rows.sort(newSortFunc);
+
+        // Move the rows to the correctly sorted order. Appending an existing DOM object just moves it!
+        cRowIndex=0;
+        var displayedCount=0;
+        var f=[removeClass,addClass];
+        if (cRow=rows[cRowIndex]){
+          do { 
+            tb.appendChild(cRow[1]); 
+          } while (cRow=rows[++cRowIndex])
+        }
+      }
+    }
+
+    // If paging is enabled on the table, then we need to re-page because the order of rows has changed!
+    if (tdata.pagesize) {
+      this.page(t); // This will internally do the striping
+    }
+    else {
+      // Re-stripe if a class name was supplied
+      if (tdata.stripeclass) {
+        this.stripe(t,tdata.stripeclass,!!tdata.ignorehiddenrows);
+      }
+    }
+  };
+
+  /**
+  * Apply a filter to rows in a table and hide those that do not match.
+  */
+  table.filter = function(o,filters,args) {
+    var cell;
+    args = args || {};
+
+    var t = this.resolve(o,args);
+    var tdata = this.tabledata[t.id];
+
+    // If new filters were passed in, apply them to the table's list of filters
+    if (!filters) {
+      // If a null or blank value was sent in for 'filters' then that means reset the table to no filters
+      tdata.filters = null;
+    }
+    else {
+      // Allow for passing a select list in as the filter, since this is common design
+      if (filters.nodeName=="SELECT" && filters.type=="select-one" && filters.selectedIndex>-1) {
+        filters={ 'filter':filters.options[filters.selectedIndex].value };
+      }
+      // Also allow for a regular input
+      if (filters.nodeName=="INPUT" && filters.type=="text") {
+        filters={ 'filter':"/^"+filters.value+"/" };
+      }
+      // Force filters to be an array
+      if (typeof(filters)=="object" && !filters.length) {
+        filters = [filters];
+      }
+
+      // Convert regular expression strings to RegExp objects and function strings to function objects
+      for (var i=0,L=filters.length; i<L; i++) {
+        var filter = filters[i];
+        if (typeof(filter.filter)=="string") {
+          // If a filter string is like "/expr/" then turn it into a Regex
+          if (filter.filter.match(/^\/(.*)\/$/)) {
+            filter.filter = new RegExp(RegExp.$1);
+            filter.filter.regex=true;
+          }
+          // If filter string is like "function (x) { ... }" then turn it into a function
+          else if (filter.filter.match(/^function\s*\(([^\)]*)\)\s*\{(.*)}\s*$/)) {
+            filter.filter = Function(RegExp.$1,RegExp.$2);
+          }
+        }
+        // If some non-table object was passed in rather than a 'col' value, resolve it 
+        // and assign it's column index to the filter if it doesn't have one. This way, 
+        // passing in a cell reference or a select object etc instead of a table object 
+        // will automatically set the correct column to filter.
+        if (filter && !def(filter.col) && (cell=getParent(o,"TD","TH"))) {
+          filter.col = this.getCellIndex(cell);
+        }
+
+        // Apply the passed-in filters to the existing list of filters for the table, removing those that have a filter of null or ""
+        if ((!filter || !filter.filter) && tdata.filters) {
+          delete tdata.filters[filter.col];
+        }
+        else {
+          tdata.filters = tdata.filters || {};
+          tdata.filters[filter.col] = filter.filter;
+        }
+      }
+      // If no more filters are left, then make sure to empty out the filters object
+      for (var j in tdata.filters) { var keep = true; }
+      if (!keep) {
+        tdata.filters = null;
+      }
+    }    
+    // Everything's been setup, so now scrape the table rows
+    return table.scrape(o);
+  };
+
+  /**
+   * "Page" a table by showing only a subset of the rows
+   */
+  table.page = function(t,page,args) {
+    args = args || {};
+    if (def(page)) { args.page = page; }
+    return table.scrape(t,args);
+  };
+
+  /**
+   * Jump forward or back any number of pages
+   */
+  table.pageJump = function(t,count,args) {
+    t = this.resolve(t,args);
+    return this.page(t,(table.tabledata[t.id].page||0)+count,args);
+  };
+
+  /**
+   * Go to the next page of a paged table
+   */  
+  table.pageNext = function(t,args) {
+    return this.pageJump(t,1,args);
+  };
+
+  /**
+   * Go to the previous page of a paged table
+   */  
+  table.pagePrevious = function(t,args) {
+    return this.pageJump(t,-1,args);
+  };
+
+  /**
+  * Scrape a table to either hide or show each row based on filters and paging
+  */
+  table.scrape = function(o,args) {
+    var col,cell,filterList,filterReset=false,filter;
+    var page,pagesize,pagestart,pageend;
+    var unfilteredrows=[],unfilteredrowcount=0,totalrows=0;
+    var t,tdata,row,hideRow;
+    args = args || {};
+
+    // Resolve the table object
+    t = this.resolve(o,args);
+    tdata = this.tabledata[t.id];
+
+    // Setup for Paging
+    var page = tdata.page;
+    if (def(page)) {
+      // Don't let the page go before the beginning
+      if (page<0) { tdata.page=page=0; }
+      pagesize = tdata.pagesize || 25; // 25=arbitrary default
+      pagestart = page*pagesize+1;
+      pageend = pagestart + pagesize - 1;
+    }
+
+    // Scrape each row of each tbody
+    var bodies = t.tBodies;
+    if (bodies==null || bodies.length==0) { return; }
+    for (var i=0,L=bodies.length; i<L; i++) {
+      var tb = bodies[i];
+      for (var j=0,L2=tb.rows.length; j<L2; j++) {
+        row = tb.rows[j];
+        hideRow = false;
+
+        // Test if filters will hide the row
+        if (tdata.filters && row.cells) {
+          var cells = row.cells;
+          var cellsLength = cells.length;
+          // Test each filter
+          for (col in tdata.filters) {
+            if (!hideRow) {
+              filter = tdata.filters[col];
+              if (filter && col<cellsLength) {
+                var val = this.getCellValue(cells[col]);
+                if (filter.regex && val.search) {
+                  hideRow=(val.search(filter)<0);
+                }
+                else if (typeof(filter)=="function") {
+                  hideRow=!filter(val,cells[col]);
+                }
+                else {
+                  hideRow = (val!=filter);
+                }
+              }
+            }
+          }
+        }
+
+        // Keep track of the total rows scanned and the total runs _not_ filtered out
+        totalrows++;
+        if (!hideRow) {
+          unfilteredrowcount++;
+          if (def(page)) {
+            // Temporarily keep an array of unfiltered rows in case the page we're on goes past
+            // the last page and we need to back up. Don't want to filter again!
+            unfilteredrows.push(row);
+            if (unfilteredrowcount<pagestart || unfilteredrowcount>pageend) {
+              hideRow = true;
+            }
+          }
+        }
+
+        row.style.display = hideRow?"none":"";
+      }
+    }
+
+    if (def(page)) {
+      // Check to see if filtering has put us past the requested page index. If it has, 
+      // then go back to the last page and show it.
+      if (pagestart>=unfilteredrowcount) {
+        pagestart = unfilteredrowcount-(unfilteredrowcount%pagesize);
+        tdata.page = page = pagestart/pagesize;
+        for (var i=pagestart,L=unfilteredrows.length; i<L; i++) {
+          unfilteredrows[i].style.display="";
+        }
+      }
+    }
+
+    // Loop through all THEADs and add/remove filtered class names
+    this.processTableCells(t,"THEAD",
+      function(c) {
+        ((tdata.filters && def(tdata.filters[table.getCellIndex(c)]) && hasClass(c,table.FilterableClassName))?addClass:removeClass)(c,table.FilteredClassName);
+      }
+    );
+
+    // Stripe the table if necessary
+    if (tdata.stripeclass) {
+      this.stripe(t);
+    }
+
+    // Calculate some values to be returned for info and updating purposes
+    var pagecount = Math.floor(unfilteredrowcount/pagesize)+1;
+    if (def(page)) {
+      // Update the page number/total containers if they exist
+      if (tdata.container_number) {
+        tdata.container_number.innerHTML = page+1;
+      }
+      if (tdata.container_count) {
+        tdata.container_count.innerHTML = pagecount;
+      }
+    }
+
+    // Update the row count containers if they exist
+    if (tdata.container_filtered_count) {
+      tdata.container_filtered_count.innerHTML = unfilteredrowcount;
+    }
+    if (tdata.container_all_count) {
+      tdata.container_all_count.innerHTML = totalrows;
+    }
+    return { 'data':tdata, 'unfilteredcount':unfilteredrowcount, 'total':totalrows, 'pagecount':pagecount, 'page':page, 'pagesize':pagesize };
+  };
+
+  /**
+   * Shade alternate rows, aka Stripe the table.
+   */
+  table.stripe = function(t,className,args) { 
+    args = args || {};
+    args.stripeclass = className;
+
+    t = this.resolve(t,args);
+    var tdata = this.tabledata[t.id];
+
+    var bodies = t.tBodies;
+    if (bodies==null || bodies.length==0) { 
+      return; 
+    }
+
+    className = tdata.stripeclass;
+    // Cache a shorter, quicker reference to either the remove or add class methods
+    var f=[removeClass,addClass];
+    for (var i=0,L=bodies.length; i<L; i++) {
+      var tb = bodies[i], tbrows = tb.rows, cRowIndex=0, cRow, displayedCount=0;
+      if (cRow=tbrows[cRowIndex]){
+        // The ignorehiddenrows test is pulled out of the loop for a slight speed increase.
+        // Makes a bigger difference in FF than in IE.
+        // In this case, speed always wins over brevity!
+        if (tdata.ignoreHiddenRows) {
+          do {
+            f[displayedCount++%2](cRow,className);
+          } while (cRow=tbrows[++cRowIndex])
+        }
+        else {
+          do {
+            if (!isHidden(cRow)) {
+              f[displayedCount++%2](cRow,className);
+            }
+          } while (cRow=tbrows[++cRowIndex])
+        }
+      }
+    }
+  };
+
+  /**
+   * Build up a list of unique values in a table column
+   */
+  table.getUniqueColValues = function(t,col) {
+    var values={}, bodies = this.resolve(t).tBodies;
+    for (var i=0,L=bodies.length; i<L; i++) {
+      var tbody = bodies[i];
+      for (var r=0,L2=tbody.rows.length; r<L2; r++) {
+        values[this.getCellValue(tbody.rows[r].cells[col])] = true;
+      }
+    }
+    var valArray = [];
+    for (var val in values) {
+      valArray.push(val);
+    }
+    return valArray.sort();
+  };
+
+  /**
+   * Scan the document on load and add sorting, filtering, paging etc ability automatically
+   * based on existence of class names on the table and cells.
+   */
+  table.auto = function(args) {
+    var cells = [], tables = document.getElementsByTagName("TABLE");
+    var val,tdata;
+    if (tables!=null) {
+      for (var i=0,L=tables.length; i<L; i++) {
+        var t = table.resolve(tables[i]);
+        tdata = table.tabledata[t.id];
+        if (val=classValue(t,table.StripeClassNamePrefix)) {
+          tdata.stripeclass=val;
+        }
+        // Do auto-filter if necessary
+        if (hasClass(t,table.AutoFilterClassName)) {
+          table.autofilter(t);
+        }
+        // Do auto-page if necessary
+        if (val = classValue(t,table.AutoPageSizePrefix)) {
+          table.autopage(t,{'pagesize':+val});
+        }
+        // Do auto-sort if necessary
+        if ((val = classValue(t,table.AutoSortColumnPrefix)) || (hasClass(t,table.AutoSortClassName))) {
+          table.autosort(t,{'col':(val==null)?null:+val});
+        }
+        // Do auto-stripe if necessary
+        if (tdata.stripeclass && hasClass(t,table.AutoStripeClassName)) {
+          table.stripe(t);
+        }
+      }
+    }
+  };
+
+  /**
+   * Add sorting functionality to a table header cell
+   */
+  table.autosort = function(t,args) {
+    t = this.resolve(t,args);
+    var tdata = this.tabledata[t.id];
+    this.processTableCells(t, "THEAD", function(c) {
+      var type = classValue(c,table.SortableColumnPrefix);
+      if (type!=null) {
+        type = type || "default";
+        c.title =c.title || table.AutoSortTitle;
+        addClass(c,table.SortableClassName);
+        c.onclick = Function("","Table.sort(this,{'sorttype':Sort['"+type+"']})");
+        // If we are going to auto sort on a column, we need to keep track of what kind of sort it will be
+        if (args.col!=null) {
+          if (args.col==table.getActualCellIndex(c)) {
+            tdata.sorttype=Sort['"+type+"'];
+          }
+        }
+      }
+    } );
+    if (args.col!=null) {
+      table.sort(t,args);
+    }
+  };
+
+  /**
+   * Add paging functionality to a table 
+   */
+  table.autopage = function(t,args) {
+    t = this.resolve(t,args);
+    var tdata = this.tabledata[t.id];
+    if (tdata.pagesize) {
+      this.processTableCells(t, "THEAD,TFOOT", function(c) {
+        var type = classValue(c,table.AutoPageJumpPrefix);
+        if (type=="next") { type = 1; }
+        else if (type=="previous") { type = -1; }
+        if (type!=null) {
+          c.onclick = Function("","Table.pageJump(this,"+type+")");
+        }
+      } );
+      if (val = classValue(t,table.PageNumberPrefix)) {
+        tdata.container_number = document.getElementById(val);
+      }
+      if (val = classValue(t,table.PageCountPrefix)) {
+        tdata.container_count = document.getElementById(val);
+      }
+      return table.page(t,0,args);
+    }
+  };
+
+  /**
+   * A util function to cancel bubbling of clicks on filter dropdowns
+   */
+  table.cancelBubble = function(e) {
+    e = e || window.event;
+    if (typeof(e.stopPropagation)=="function") { e.stopPropagation(); } 
+    if (def(e.cancelBubble)) { e.cancelBubble = true; }
+  };
+
+  /**
+   * Auto-filter a table
+   */
+  table.autofilter = function(t,args) {
+    args = args || {};
+    t = this.resolve(t,args);
+    var tdata = this.tabledata[t.id],val;
+    table.processTableCells(t, "THEAD", function(cell) {
+      if (hasClass(cell,table.FilterableClassName)) {
+        var cellIndex = table.getCellIndex(cell);
+        var colValues = table.getUniqueColValues(t,cellIndex);
+        if (colValues.length>0) {
+          if (typeof(args.insert)=="function") {
+            func.insert(cell,colValues);
+          }
+          else {
+            var sel = '<select onchange="Table.filter(this,this)" onclick="Table.cancelBubble(event)" class="'+table.AutoFilterClassName+'"><option value="">'+table.FilterAllLabel+'</option>';
+            for (var i=0; i<colValues.length; i++) {
+              sel += '<option value="'+colValues[i]+'">'+colValues[i]+'</option>';
+            }
+            sel += '</select>';
+            cell.innerHTML += "<br>"+sel;
+          }
+        }
+      }
+    });
+    if (val = classValue(t,table.FilteredRowcountPrefix)) {
+      tdata.container_filtered_count = document.getElementById(val);
+    }
+    if (val = classValue(t,table.RowcountPrefix)) {
+      tdata.container_all_count = document.getElementById(val);
+    }
+  };
+
+  /**
+   * Attach the auto event so it happens on load.
+   * use jQuery's ready() function if available
+   */
+  if (typeof(jQuery)!="undefined") {
+    jQuery(table.auto);
+  }
+  else if (window.addEventListener) {
+    window.addEventListener( "load", table.auto, false );
+  }
+  else if (window.attachEvent) {
+    window.attachEvent( "onload", table.auto );
+  }
+
+  return table;
+})();
diff --git a/test/testConfluence.pl b/test/testConfluence.pl
new file mode 100755 (executable)
index 0000000..6ec842e
--- /dev/null
@@ -0,0 +1,20 @@
+#/usr/bin/env perl
+use strict;
+use warnings;
+
+use FindBin;
+
+use lib "$FindBin::Bin/../lib";
+
+use Confluence;
+
+my $confluence = Confluence->new (
+  username => 'adefaria',
+  server   => 'confluence',
+  port     => 8080,
+);
+
+my $content = $confluence->getContent (
+  title => 'Knowles Migration',
+);
+
diff --git a/test/testldap.pl b/test/testldap.pl
new file mode 100755 (executable)
index 0000000..a5c7602
--- /dev/null
@@ -0,0 +1,61 @@
+#!/usr/bin/env perl
+use strict;
+use warnings;
+
+use Net::LDAP;
+use Carp;
+
+sub getUserEmail ($) {
+  my ($userid) = @_;
+  
+  my (@entries, $result);
+
+  my %opts = (
+    KNOWLES_AD_HOST   => '10.252.2.28',
+    KNOWLES_AD_PORT   => 389,
+    KNOWLES_AD_BASEDN => 'DC=knowles,DC=com',
+    KNOWLES_AD_BINDDN => 'CN=AD Reader,OU=Users,OU=KMV,OU=Knowles,DC=knowles,DC=com',
+    KNOWLES_AD_BINDPW => '@Dre@D2015',
+  );
+  
+  my $mailAttribute = 'mail';
+
+  print "Creating new LDAP object for Knowles\n";  
+  my $knowlesLDAP = Net::LDAP->new (
+    $opts{KNOWLES_AD_HOST}, (
+      host   => $opts{KNOWLES_AD_HOST},
+      port   => $opts{KNOWLES_AD_PORT},
+      basedn => $opts{KNOWLES_AD_BASEDN},
+      #binddn => $opts{KNOWLES_AD_BINDDN},
+      #bindpw => $opts{KNOWLES_AD_BINDPW},
+    )
+  ) or croak $@;
+  
+  print "Binding anonymously\n";  
+#  if ($opts{KNOWLES_AD_BINDDN}) {
+     $result = $knowlesLDAP->bind (
+#      dn       => $opts{KNOWLES_AD_BINDDN},
+#      password => $opts{KNOWLES_AD_BINDPW},
+    ) or croak "Unable to bind\n$@";
+
+  croak "Unable to bind (Error " . $result->code . "\n" . $result->error
+    if $result->code;
+  
+  print "Searching for $userid\n";  
+  $result = $knowlesLDAP->search (
+    base   => $opts{KNOWLES_AD_BASEDN},
+    filter => "sAMAccountName=$userid",
+  );
+  
+  print "Getting entries\n";
+  @entries = ($result->entries);
+    
+  if ($entries[0]) {
+    return $entries[0]->get_value ($mailAttribute);
+  } else {
+    return 'Unknown';
+  } # if
+} # getUserEmail
+
+print getUserEmail ('adefari');
+print "\n";
\ No newline at end of file