[Rt-commit] rt branch, 4.4/dump-and-merge-initialdata, created. rt-4.4.3-140-ge63b7e438

? sunnavy sunnavy at bestpractical.com
Mon Aug 6 15:54:20 EDT 2018


The branch, 4.4/dump-and-merge-initialdata has been created
        at  e63b7e43856a1cdf58ce7819ed42c59dddf46539 (commit)

- Log -----------------------------------------------------------------
commit 819a7b31e108b7ab6dd636a3452420a12c32e694
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Fri Aug 3 02:06:23 2018 +0800

    Fix typo to make @Assets really work

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index eaf3d76e3..cba68a240 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1141,7 +1141,7 @@ sub InsertData {
     if ( @Assets ) {
         $RT::Logger->debug("Creating Assets...");
 
-        for my $item (@Catalogs) {
+        for my $item (@Assets) {
             my $new_entry = RT::Asset->new(RT->SystemUser);
             my ( $return, $msg ) = $new_entry->Create(%$item);
             unless ( $return ) {

commit 88e2980396c7372c8682db616c263961be18da7c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 16:11:03 2017 +0000

    Add support for asset and catalog attributes for initialdata

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index cba68a240..009a98161 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1126,6 +1126,7 @@ sub InsertData {
         $RT::Logger->debug("Creating Catalogs...");
 
         for my $item (@Catalogs) {
+            my $attributes = delete $item->{ Attributes };
             my $new_entry = RT::Catalog->new(RT->SystemUser);
             my ( $return, $msg ) = $new_entry->Create(%$item);
             unless ( $return ) {
@@ -1134,6 +1135,9 @@ sub InsertData {
             else {
                 $RT::Logger->debug( $return ."." );
             }
+
+            $_->{Object} = $new_entry for @{$attributes || []};
+            push @Attributes, @{$attributes || []};
         }
 
         $RT::Logger->debug("done.");
@@ -1142,6 +1146,7 @@ sub InsertData {
         $RT::Logger->debug("Creating Assets...");
 
         for my $item (@Assets) {
+            my $attributes = delete $item->{ Attributes };
             my $new_entry = RT::Asset->new(RT->SystemUser);
             my ( $return, $msg ) = $new_entry->Create(%$item);
             unless ( $return ) {
@@ -1150,6 +1155,9 @@ sub InsertData {
             else {
                 $RT::Logger->debug( $return ."." );
             }
+
+            $_->{Object} = $new_entry for @{$attributes || []};
+            push @Attributes, @{$attributes || []};
         }
 
         $RT::Logger->debug("done.");

commit 8e0dd77bebe2fdaf9f937dfed9f96badd8f4d02e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 16:21:42 2017 +0000

    Add support for OCFVs in initialdata

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 009a98161..fb43fc577 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -853,10 +853,10 @@ sub InsertData {
     # Slurp in stuff to insert from the datafile. Possible things to go in here:-
     our (@Groups, @Users, @Members, @ACL, @Queues, @Classes, @ScripActions, @ScripConditions,
            @Templates, @CustomFields, @CustomRoles, @Scrips, @Attributes, @Initial, @Final,
-           @Catalogs, @Assets);
+           @Catalogs, @Assets, @OCFVs);
     local (@Groups, @Users, @Members, @ACL, @Queues, @Classes, @ScripActions, @ScripConditions,
            @Templates, @CustomFields, @CustomRoles, @Scrips, @Attributes, @Initial, @Final,
-           @Catalogs, @Assets);
+           @Catalogs, @Assets, @OCFVs);
 
     local $@;
 
@@ -907,6 +907,7 @@ sub InsertData {
                 Final           => \@Final,
                 Catalogs        => \@Catalogs,
                 Assets          => \@Assets,
+                OCFVs           => \@OCFVs,
             },
         ) or return (0, "Couldn't load data from '$datafile' for import:\n\nERROR:" . $@);
     }
@@ -930,6 +931,8 @@ sub InsertData {
         $RT::Logger->debug("Creating groups...");
         foreach my $item (@Groups) {
             my $attributes = delete $item->{ Attributes };
+            my $ocfvs = delete $item->{ CustomFields };
+
             my $new_entry = RT::Group->new( RT->SystemUser );
             $item->{'Domain'} ||= 'UserDefined';
             my $member_of = delete $item->{'MemberOf'};
@@ -942,6 +945,8 @@ sub InsertData {
                 $RT::Logger->debug($return .".");
                 $_->{Object} = $new_entry for @{$attributes || []};
                 push @Attributes, @{$attributes || []};
+                $_->{Object} = $new_entry for @{$ocfvs || []};
+                push @OCFVs, @{$ocfvs || []};
             }
             if ( $member_of ) {
                 $member_of = [ $member_of ] unless ref $member_of eq 'ARRAY';
@@ -991,6 +996,7 @@ sub InsertData {
                 $item->{'Password'} = $root_password;
             }
             my $attributes = delete $item->{ Attributes };
+            my $ocfvs = delete $item->{ CustomFields };
 
             no warnings 'redefine';
             local *RT::User::CanonicalizeUserInfo = sub { 1 }
@@ -1004,6 +1010,8 @@ sub InsertData {
                 $RT::Logger->debug( $return ."." );
                 $_->{Object} = $new_entry for @{$attributes || []};
                 push @Attributes, @{$attributes || []};
+                $_->{Object} = $new_entry for @{$ocfvs || []};
+                push @OCFVs, @{$ocfvs || []};
             }
             if ( $member_of ) {
                 $member_of = [ $member_of ] unless ref $member_of eq 'ARRAY';
@@ -1070,6 +1078,8 @@ sub InsertData {
         $RT::Logger->debug("Creating queues...");
         for my $item (@Queues) {
             my $attributes = delete $item->{ Attributes };
+            my $ocfvs = delete $item->{ CustomFields };
+
             my $new_entry = RT::Queue->new(RT->SystemUser);
             my ( $return, $msg ) = $new_entry->Create(%$item);
             unless ( $return ) {
@@ -1078,6 +1088,8 @@ sub InsertData {
                 $RT::Logger->debug( $return ."." );
                 $_->{Object} = $new_entry for @{$attributes || []};
                 push @Attributes, @{$attributes || []};
+                $_->{Object} = $new_entry for @{$ocfvs || []};
+                push @OCFVs, @{$ocfvs || []};
             }
         }
         $RT::Logger->debug("done.");
@@ -1147,6 +1159,8 @@ sub InsertData {
 
         for my $item (@Assets) {
             my $attributes = delete $item->{ Attributes };
+            my $ocfvs = delete $item->{ CustomFields };
+
             my $new_entry = RT::Asset->new(RT->SystemUser);
             my ( $return, $msg ) = $new_entry->Create(%$item);
             unless ( $return ) {
@@ -1158,6 +1172,8 @@ sub InsertData {
 
             $_->{Object} = $new_entry for @{$attributes || []};
             push @Attributes, @{$attributes || []};
+            $_->{Object} = $new_entry for @{$ocfvs || []};
+            push @OCFVs, @{$ocfvs || []};
         }
 
         $RT::Logger->debug("done.");
@@ -1446,6 +1462,31 @@ sub InsertData {
         }
         $RT::Logger->debug("done.");
     }
+
+    if ( @OCFVs ) {
+        $RT::Logger->debug("Creating ObjectCustomFieldValues...");
+
+        for my $item (@OCFVs) {
+            my $obj = delete $item->{Object};
+
+            if ( ref $obj eq 'CODE' ) {
+                $obj = $obj->();
+            }
+
+            $item->{Field} = delete $item->{CustomField} if $item->{CustomField};
+            $item->{Value} = delete $item->{Content} if $item->{Content};
+
+            my ( $return, $msg ) = $obj->AddCustomFieldValue (%$item);
+            unless ( $return ) {
+                $RT::Logger->error( $msg );
+            }
+            else {
+                $RT::Logger->debug( $return ."." );
+            }
+        }
+        $RT::Logger->debug("done.");
+    }
+
     if ( @Attributes ) {
         $RT::Logger->debug("Creating attributes...");
         my $sys = RT::System->new(RT->SystemUser);

commit 6dbe329359f8f80389b509061d6ade6ab91b3d73
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:26:50 2017 +0000

    Insert @Members later, after queues, assets, custom roles etc have been created

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index fb43fc577..bd18b9b52 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1047,33 +1047,6 @@ sub InsertData {
         }
         $RT::Logger->debug("done.");
     }
-    if ( @Members ) {
-        $RT::Logger->debug("Adding users and groups to groups...");
-        for my $item (@Members) {
-            my $group = RT::Group->new(RT->SystemUser);
-            $group->LoadUserDefinedGroup( delete $item->{Group} );
-            unless ($group->Id) {
-                RT->Logger->error("Unable to find group '$group' to add members to");
-                next;
-            }
-
-            my $class = delete $item->{Class} || 'RT::User';
-            my $member = $class->new( RT->SystemUser );
-            $item->{Domain} = 'UserDefined' if $member->isa("RT::Group");
-            $member->LoadByCols( %$item );
-            unless ($member->Id) {
-                RT->Logger->error("Unable to find $class '".($item->{id} || $item->{Name})."' to add to ".$group->Name);
-                next;
-            }
-
-            my ( $return, $msg) = $group->AddMember( $member->PrincipalObj->Id );
-            unless ( $return ) {
-                $RT::Logger->error( $msg );
-            } else {
-                $RT::Logger->debug( $return ."." );
-            }
-        }
-    }
     if ( @Queues ) {
         $RT::Logger->debug("Creating queues...");
         for my $item (@Queues) {
@@ -1296,6 +1269,34 @@ sub InsertData {
         $RT::Logger->debug("done.");
     }
 
+    if ( @Members ) {
+        $RT::Logger->debug("Adding users and groups to groups...");
+        for my $item (@Members) {
+            my $group = RT::Group->new(RT->SystemUser);
+            $group->LoadUserDefinedGroup( delete $item->{Group} );
+            unless ($group->Id) {
+                RT->Logger->error("Unable to find group '$group' to add members to");
+                next;
+            }
+
+            my $class = delete $item->{Class} || 'RT::User';
+            my $member = $class->new( RT->SystemUser );
+            $item->{Domain} = 'UserDefined' if $member->isa("RT::Group");
+            $member->LoadByCols( %$item );
+            unless ($member->Id) {
+                RT->Logger->error("Unable to find $class '".($item->{id} || $item->{Name})."' to add to ".$group->Name);
+                next;
+            }
+
+            my ( $return, $msg) = $group->AddMember( $member->PrincipalObj->Id );
+            unless ( $return ) {
+                $RT::Logger->error( $msg );
+            } else {
+                $RT::Logger->debug( $return ."." );
+            }
+        }
+    }
+
     if ( @ACL ) {
         $RT::Logger->debug("Creating ACL...");
         for my $item (@ACL) {

commit b913d8ec6f140ad6dd248528f56ad50915c71ac3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 20:18:32 2017 +0000

    Support all the role group domains for @ACL

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index bd18b9b52..53cb36ea1 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1348,9 +1348,7 @@ sub InsertData {
                   $princ->LoadSystemInternalGroup( $item->{'GroupType'} );
                 } elsif ( $item->{'GroupDomain'} eq 'RT::System-Role' ) {
                   $princ->LoadRoleGroup( Object => RT->System, Name => $item->{'GroupType'} );
-                } elsif ( $item->{'GroupDomain'} eq 'RT::Queue-Role' &&
-                          $item->{'Queue'} )
-                {
+                } elsif ( $item->{'GroupDomain'} =~ /-Role$/ ) {
                   $princ->LoadRoleGroup( Object => $object, Name => $item->{'GroupType'} );
                 } else {
                   $princ->Load( $item->{'GroupId'} );

commit cd9a04b073b78ccfbd1cdcc230a7a4eaa3220aba
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 21:05:59 2017 +0000

    initialdata support for @Articles

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 53cb36ea1..907475135 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -853,10 +853,10 @@ sub InsertData {
     # Slurp in stuff to insert from the datafile. Possible things to go in here:-
     our (@Groups, @Users, @Members, @ACL, @Queues, @Classes, @ScripActions, @ScripConditions,
            @Templates, @CustomFields, @CustomRoles, @Scrips, @Attributes, @Initial, @Final,
-           @Catalogs, @Assets, @OCFVs);
+           @Catalogs, @Assets, @Articles, @OCFVs);
     local (@Groups, @Users, @Members, @ACL, @Queues, @Classes, @ScripActions, @ScripConditions,
            @Templates, @CustomFields, @CustomRoles, @Scrips, @Attributes, @Initial, @Final,
-           @Catalogs, @Assets, @OCFVs);
+           @Catalogs, @Assets, @Articles, @OCFVs);
 
     local $@;
 
@@ -907,6 +907,7 @@ sub InsertData {
                 Final           => \@Final,
                 Catalogs        => \@Catalogs,
                 Assets          => \@Assets,
+                Articles        => \@Articles,
                 OCFVs           => \@OCFVs,
             },
         ) or return (0, "Couldn't load data from '$datafile' for import:\n\nERROR:" . $@);
@@ -1152,6 +1153,31 @@ sub InsertData {
         $RT::Logger->debug("done.");
     }
 
+    if ( @Articles ) {
+        $RT::Logger->debug("Creating Articles...");
+
+        for my $item (@Articles) {
+            my $attributes = delete $item->{ Attributes };
+            my $ocfvs = delete $item->{ CustomFields };
+
+            my $new_entry = RT::Article->new(RT->SystemUser);
+            my ( $return, $msg ) = $new_entry->Create(%$item);
+            unless ( $return ) {
+                $RT::Logger->error( $msg );
+            }
+            else {
+                $RT::Logger->debug( $return ."." );
+            }
+
+            $_->{Object} = $new_entry for @{$attributes || []};
+            push @Attributes, @{$attributes || []};
+            $_->{Object} = $new_entry for @{$ocfvs || []};
+            push @OCFVs, @{$ocfvs || []};
+        }
+
+        $RT::Logger->debug("done.");
+    }
+
 
     if ( @CustomFields ) {
         $RT::Logger->debug("Creating custom fields...");

commit 6a5e96997f384baeebe72c2e09954fe7e28b9a06
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 22:17:10 2017 +0000

    Defer setting BasedOn for CFs later in the @CustomFields list

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 907475135..eb60569c0 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1181,6 +1181,8 @@ sub InsertData {
 
     if ( @CustomFields ) {
         $RT::Logger->debug("Creating custom fields...");
+        my @deferred_BasedOn;
+
         for my $item ( @CustomFields ) {
             my $attributes = delete $item->{ Attributes };
             my $new_entry = RT::CustomField->new( RT->SystemUser );
@@ -1208,8 +1210,7 @@ sub InsertData {
                     if ($ok) {
                         $item->{'BasedOn'} = $basedon->Id;
                     } else {
-                        $RT::Logger->error("Unable to load $item->{BasedOn} as a $item->{LookupType} CF.  Skipping BasedOn: $msg");
-                        delete $item->{'BasedOn'};
+                        push @deferred_BasedOn, [$new_entry, delete $item->{'BasedOn'}];
                     }
                 } else {
                     $RT::Logger->error("Unable to load CF $item->{BasedOn} because no LookupType was specified.  Skipping BasedOn");
@@ -1263,6 +1264,23 @@ sub InsertData {
             push @Attributes, @{$attributes || []};
         }
 
+        for ( @deferred_BasedOn ) {
+            my ($cf, $name) = @$_;
+            my $basedon = RT::CustomField->new($RT::SystemUser);
+            my ($ok, $msg ) = $basedon->LoadByCols(
+                Name => $name,
+                LookupType => $cf->LookupType,
+                Disabled => 0,
+            );
+            if ($ok) {
+                ($ok, $msg) = $cf->SetBasedOn($basedon->Id);
+                $RT::Logger->error("Unable to set $name as a " . $cf->LookupType . " BasedOn CF: $msg") if !$ok;
+            }
+            else {
+                $RT::Logger->error("Unable to load $name as a " . $cf->LookupType . " CF.  Skipping BasedOn: $msg");
+            }
+        }
+
         $RT::Logger->debug("done.");
     }
 

commit 1de0a488e8a9316e00dc64a806a529038dbfe98b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 21:33:54 2017 +0000

    Handle ObjectScrip Stage in initialdata

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index eb60569c0..36a6da923 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1495,11 +1495,14 @@ sub InsertData {
                 $RT::Logger->debug( $return ."." );
             }
             foreach my $q ( @queues ) {
-                my ($return, $msg) = $new_entry->AddToObject(
-                    ObjectId => $q,
-                    Stage    => $item->{'Stage'},
+                my %args = (
+                    Stage => $item->{'Stage'},
+                    (ref($q) ? %$q : (ObjectId => $q)),
                 );
-                $RT::Logger->error( "Couldn't apply scrip to $q: $msg" )
+
+                my ($return, $msg) = $new_entry->AddToObject(%args);
+
+                $RT::Logger->error( "Couldn't apply scrip to $args{ObjectId}: $msg" )
                     unless $return;
             }
         }

commit ee902438a4aa6699011bb0af5c6deb573b4f1d3b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:53:01 2017 +0000

    Handle passing a hash with Stage as first param to Queue

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 36a6da923..ca207ef73 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1486,7 +1486,18 @@ sub InsertData {
             my @queues = ref $item->{'Queue'} eq 'ARRAY'? @{ $item->{'Queue'} }: $item->{'Queue'} || 0;
             push @queues, 0 unless @queues; # add global queue at least
 
-            my ( $return, $msg ) = $new_entry->Create( %$item, Queue => shift @queues );
+            my %args = %$item;
+            $args{Queue} = shift @queues;
+            if (ref($args{Queue})) {
+                # transform ScripObject->Create API into Scrip->Create API
+                $args{Queue}{Queue} = delete $args{Queue}{ObjectId};
+                %args = (
+                    %args,
+                    %{ $args{Queue} },
+                );
+            }
+
+            my ( $return, $msg ) = $new_entry->Create(%args);
             unless ( $return ) {
                 $RT::Logger->error( $msg );
                 next;

commit c21aab9bdbc7e60fb74382812404fa1f21bd0c7c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 00:31:27 2017 +0000

    Fixing up ObjectScrip sort order

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index ca207ef73..207a3fece 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1491,6 +1491,7 @@ sub InsertData {
             if (ref($args{Queue})) {
                 # transform ScripObject->Create API into Scrip->Create API
                 $args{Queue}{Queue} = delete $args{Queue}{ObjectId};
+                $args{Queue}{ObjectSortOrder} = delete $args{Queue}{SortOrder};
                 %args = (
                     %args,
                     %{ $args{Queue} },
diff --git a/lib/RT/Scrip.pm b/lib/RT/Scrip.pm
index 10f0605c2..e015bb39a 100644
--- a/lib/RT/Scrip.pm
+++ b/lib/RT/Scrip.pm
@@ -200,9 +200,10 @@ sub Create {
     return ( $id, $msg ) unless $id;
 
     (my $status, $msg) = RT::ObjectScrip->new( $self->CurrentUser )->Add(
-        Scrip    => $self,
-        Stage    => $args{'Stage'},
-        ObjectId => $args{'Queue'},
+        Scrip     => $self,
+        Stage     => $args{'Stage'},
+        ObjectId  => $args{'Queue'},
+        SortOrder => $args{'ObjectSortOrder'},
     );
     $RT::Logger->error( "Couldn't add scrip: $msg" ) unless $status;
 

commit 33edad4e6b591cc9219b34676b73f702d30d8c06
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:36:12 2017 +0000

    Support GroupDomain for item in @Members

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 207a3fece..1bb840750 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1316,10 +1316,16 @@ sub InsertData {
     if ( @Members ) {
         $RT::Logger->debug("Adding users and groups to groups...");
         for my $item (@Members) {
+            my $name = delete $item->{Group};
+            my $domain = delete $item->{GroupDomain} || 'UserDefined';
+
             my $group = RT::Group->new(RT->SystemUser);
-            $group->LoadUserDefinedGroup( delete $item->{Group} );
+            $group->LoadByCols(
+                Name => $name,
+                Domain => $domain,
+            );
             unless ($group->Id) {
-                RT->Logger->error("Unable to find group '$group' to add members to");
+                RT->Logger->error("Unable to find $domain group '$name' to add members to");
                 next;
             }
 

commit b9eab03870a4faaaef5a3407f55794bd01d057f0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 01:09:44 2017 +0000

    NoAutoGlobal option for @Scrips

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 1bb840750..9595da012 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1489,8 +1489,16 @@ sub InsertData {
         for my $item (@Scrips) {
             my $new_entry = RT::Scrip->new(RT->SystemUser);
 
-            my @queues = ref $item->{'Queue'} eq 'ARRAY'? @{ $item->{'Queue'} }: $item->{'Queue'} || 0;
-            push @queues, 0 unless @queues; # add global queue at least
+            my @queues = ref $item->{'Queue'} eq 'ARRAY'
+                       ? @{ $item->{'Queue'} }
+                       : ($item->{'Queue'})
+                if $item->{'Queue'};
+
+            if (!@queues) {
+                push @queues, 0 unless $item->{'NoAutoGlobal'};
+            }
+
+            my $remove_global = (delete $item->{'NoAutoGlobal'}) && !@queues;
 
             my %args = %$item;
             $args{Queue} = shift @queues;
@@ -1512,6 +1520,13 @@ sub InsertData {
             else {
                 $RT::Logger->debug( $return ."." );
             }
+
+            if ($remove_global) {
+                my ($return, $msg) = $new_entry->RemoveFromObject(ObjectId => 0);
+                $RT::Logger->error( "Couldn't unapply scrip globally: $msg" )
+                    unless $return;
+            }
+
             foreach my $q ( @queues ) {
                 my %args = (
                     Stage => $item->{'Stage'},

commit 984db79697971c74c6a558e7564df6d653182c5e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 20:00:29 2017 +0000

    Support import of queue watcher groups

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 9595da012..9c4542d70 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1318,11 +1318,24 @@ sub InsertData {
         for my $item (@Members) {
             my $name = delete $item->{Group};
             my $domain = delete $item->{GroupDomain} || 'UserDefined';
+            my $instance = delete $item->{GroupInstance};
+
+            if ($domain =~ /^(.+)-Role$/) {
+                 my $class = $1;
+                 if (!$class->DOES("RT::Record::Role::Roles")) {
+                     RT->Logger->error("Invalid group domain '$domain' for group $name; skipping adding membership");
+                     next;
+                 }
+                 my $object = $class->new(RT->SystemUser);
+                 $object->Load($instance);
+                 $instance = $object->Id;
+            }
 
             my $group = RT::Group->new(RT->SystemUser);
             $group->LoadByCols(
                 Name => $name,
                 Domain => $domain,
+                (defined $instance ? (Instance => $instance) : ()),
             );
             unless ($group->Id) {
                 RT->Logger->error("Unable to find $domain group '$name' to add members to");

commit 05d21ca3a33f8cb9f29b8bbec1a0d521b32dc053
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:04:54 2017 +0000

    Handle explicit ApplyTo => 0 to make a CF which is global

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 9c4542d70..7ec75feab 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1232,22 +1232,25 @@ sub InsertData {
 
             my $class = $new_entry->RecordClassFromLookupType;
             if ($class) {
-                if ($new_entry->IsOnlyGlobal and $apply_to) {
-                    $RT::Logger->warn("ApplyTo provided for global custom field ".$new_entry->Name );
-                    undef $apply_to;
-                }
-                if ( !$apply_to ) {
-                    # Apply to all by default
+                $apply_to = [ $apply_to ] unless ref $apply_to;
+                for my $name ( @{ $apply_to } ) {
                     my $ocf = RT::ObjectCustomField->new(RT->SystemUser);
-                    ( $return, $msg) = $ocf->Create( CustomField => $new_entry->Id );
-                    $RT::Logger->error( $msg ) unless $return and $ocf->Id;
-                } else {
-                    $apply_to = [ $apply_to ] unless ref $apply_to;
-                    for my $name ( @{ $apply_to } ) {
+
+                    # global CF
+                    if (!$name) {
+                        ( $return, $msg ) = $ocf->Create(
+                            CustomField => $new_entry->Id,
+                        );
+                        $RT::Logger->error( $msg ) unless $return and $ocf->Id;
+                    }
+                    else {
+                        if ($new_entry->IsOnlyGlobal) {
+                            $RT::Logger->warn("ApplyTo '$name' provided for global custom field ".$new_entry->Name );
+                        }
+
                         my $obj = $class->new(RT->SystemUser);
                         $obj->Load($name);
                         if ( $obj->Id ) {
-                            my $ocf = RT::ObjectCustomField->new(RT->SystemUser);
                             ( $return, $msg ) = $ocf->Create(
                                 CustomField => $new_entry->Id,
                                 ObjectId    => $obj->Id,

commit c92316ec9f5995d90510f50e788653afce8922c8
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:33:16 2017 +0000

    Handle RightName in initialdata
    
    This is what RT::ACE calls it

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 7ec75feab..d9f3d5419 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1432,6 +1432,8 @@ sub InsertData {
                 }
             }
 
+            $item->{Right} = delete $item->{RightName} if $item->{RightName};
+
             # Grant it
             my @rights = ref($item->{'Right'}) eq 'ARRAY' ? @{$item->{'Right'}} : $item->{'Right'};
             foreach my $right ( @rights ) {

commit c84eb72e7adbcf121fb0bd3aa54b7ab7a678cf78
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 01:12:36 2017 +0000

    Handle global Class records more consistently in initialdata

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index d9f3d5419..585102593 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1077,19 +1077,21 @@ sub InsertData {
                 $item->{'ApplyTo'} = delete $item->{'Queue'};
             }
 
-            my $apply_to = delete $item->{'ApplyTo'};
+            my $apply_to = (delete $item->{'ApplyTo'}) || 0;
+            $apply_to = [ $apply_to ] unless ref $apply_to;
+
             my $new_entry = RT::Class->new(RT->SystemUser);
             my ( $return, $msg ) = $new_entry->Create(%$item);
             unless ( $return ) {
                 $RT::Logger->error( $msg );
             } else {
                 $RT::Logger->debug( $return ."." );
-                if ( !$apply_to ) {
-                    ( $return, $msg) = $new_entry->AddToObject( RT::Queue->new(RT->SystemUser) );
-                    $RT::Logger->error( $msg ) unless $return;
-                } else {
-                    $apply_to = [ $apply_to ] unless ref $apply_to;
-                    for my $name ( @{ $apply_to } ) {
+
+                for my $name ( @{ $apply_to } ) {
+                    if ( !$name ) {
+                        ( $return, $msg) = $new_entry->AddToObject( RT::Queue->new(RT->SystemUser) );
+                        $RT::Logger->error( $msg ) unless $return;
+                    } else {
                         my $queue = RT::Queue->new( RT->SystemUser );
                         $queue->Load( $name );
                         if ( $queue->id ) {

commit a713a7b5a9d5b3b6c79c5f987665359450984510
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 20:12:21 2017 +0000

    Correctly load ACLs granted on user-defined groups

diff --git a/lib/RT/Handle.pm b/lib/RT/Handle.pm
index 585102593..3d2dcd14b 100644
--- a/lib/RT/Handle.pm
+++ b/lib/RT/Handle.pm
@@ -1390,6 +1390,14 @@ sub InsertData {
                     RT->Logger->error("Unable to load queue ".$item->{Queue}.": $msg");
                     next;
                 }
+            } elsif ( $item->{'Group'} || ($item->{ObjectType}||'') eq 'RT::Group') {
+                my $name = $item->{'Group'} || $item->{ObjectId};
+                $object = RT::Group->new(RT->SystemUser);
+                my ($ok, $msg) = $object->LoadUserDefinedGroup($name);
+                unless ( $ok ) {
+                    RT->Logger->error("Unable to load user-defined group $name: $msg");
+                    next;
+                }
             } elsif ( $item->{ObjectType} and $item->{ObjectId}) {
                 $object = $item->{ObjectType}->new(RT->SystemUser);
                 my ($ok, $msg) = $object->Load( $item->{ObjectId} );

commit db9ee856c65b64a98cd78b59ed5337099f53a908
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 19:26:43 2017 +0000

    Avoid creating duplicate SystemInternal and RT::Role-System groups
    
    We've seen copies of the SystemInternal groups in the wild, which causes
    symptoms such as privileged users being forced into self-service
    
    initialdata is one avenue by which this could happen, as there was no
    validation previously

diff --git a/lib/RT/Group.pm b/lib/RT/Group.pm
index 25823ab47..ed7137907 100644
--- a/lib/RT/Group.pm
+++ b/lib/RT/Group.pm
@@ -305,10 +305,18 @@ sub _Create {
         @_
     );
 
-    # Enforce uniqueness on user defined group names
-    if ($args{'Domain'} and $args{'Domain'} eq 'UserDefined') {
-        my ($ok, $msg) = $self->_ValidateUserDefinedName($args{'Name'});
-        return ($ok, $msg) if not $ok;
+    if ($args{'Domain'}) {
+        # Enforce uniqueness on user defined group names
+        if ($args{'Domain'} eq 'UserDefined') {
+            my ($ok, $msg) = $self->_ValidateUserDefinedName($args{'Name'});
+            return ($ok, $msg) if not $ok;
+        }
+
+        # Enforce uniqueness on SystemInternal and system role groups
+        if ($args{'Domain'} eq 'SystemInternal' || $args{'Domain'} eq 'RT::System-Role') {
+            my ($ok, $msg) = $self->_ValidateNameForDomain($args{'Name'}, $args{'Domain'});
+            return ($ok, $msg) if not $ok;
+        }
     }
 
     $RT::Handle->BeginTransaction() unless ($args{'InsideTransaction'});
@@ -398,25 +406,42 @@ sub ValidateName {
     return $self->SUPER::ValidateName($value);
 }
 
-=head2 _ValidateUserDefinedName VALUE
+=head2 _ValidateNameForDomain VALUE DOMAIN
 
-Returns true if the user defined group name isn't in use, false otherwise.
+Returns true if the group name isn't in use in the same domain, false otherwise.
 
 =cut
 
-sub _ValidateUserDefinedName {
-    my ($self, $value) = @_;
+sub _ValidateNameForDomain {
+    my ($self, $value, $domain) = @_;
 
     return (0, 'Name is required') unless length $value;
 
     my $dupcheck = RT::Group->new(RT->SystemUser);
-    $dupcheck->LoadUserDefinedGroup($value);
+    if ($domain eq 'UserDefined') {
+        $dupcheck->LoadUserDefinedGroup($value);
+    }
+    else {
+        $dupcheck->LoadByCols(Domain => $domain, Name => $value);
+    }
     if ( $dupcheck->id && ( !$self->id || $self->id != $dupcheck->id ) ) {
         return ( 0, $self->loc( "Group name '[_1]' is already in use", $value ) );
     }
     return 1;
 }
 
+=head2 _ValidateUserDefinedName VALUE
+
+Returns true if the user defined group name isn't in use, false otherwise.
+
+=cut
+
+sub _ValidateUserDefinedName {
+    my ($self, $value) = @_;
+
+    return $self->_ValidateNameForDomain($value, 'UserDefined');
+}
+
 =head2 _CreateACLEquivalenceGroup { Principal }
 
 A helper subroutine which creates a group containing only 

commit 41d321072967de442fb5ebe21b173f939823ea89
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 21:52:41 2017 +0000

    Allow passing SortOrder to CustomField->Create

diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
index 603021423..9c148ebfb 100644
--- a/lib/RT/CustomField.pm
+++ b/lib/RT/CustomField.pm
@@ -265,6 +265,7 @@ sub Create {
         Pattern                => '',
         Description            => '',
         Disabled               => 0,
+        SortOrder              => 0,
         LookupType             => '',
         LinkValueTo            => '',
         IncludeContentForValue => '',
@@ -361,6 +362,7 @@ sub Create {
         ValuesClass       => $args{'ValuesClass'},
         Description       => $args{'Description'},
         Disabled          => $args{'Disabled'},
+        SortOrder         => $args{'SortOrder'},
         LookupType        => $args{'LookupType'},
         UniqueValues      => $args{'UniqueValues'},
         CanonicalizeClass => $args{'CanonicalizeClass'},

commit 0ae4036e4ae91027accfb7b38e245886cd8b6a47
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 17:49:25 2017 +0000

    Avoid undef warnings when checking a new queue for name uniqueness
    
    This adopts the same syntactical pattern as RT::Group

diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index b9eaec302..0c8a7cdba 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -280,7 +280,7 @@ sub _ValidateName {
     $tempqueue->Load($name);
 
     #If this queue exists, return undef
-    if ( $tempqueue->Name() && $tempqueue->id != $self->id)  {
+    if ( $tempqueue->Name() && ( !$self->id || $tempqueue->id != $self->id ) ) {
         return (undef, $self->loc("Queue already exists") );
     }
 

commit 5896c8acc0c2e81a04e29d94e63fd0624d5c5eb1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 20:09:56 2017 +0000

    When serializing an ACE, make sure its user is too
    
    Without this we may serialize an ACE and a user's ACLEquivalence group,
    but not the user itself

diff --git a/lib/RT/ACE.pm b/lib/RT/ACE.pm
index 04a6c5003..0fcae1f4a 100644
--- a/lib/RT/ACE.pm
+++ b/lib/RT/ACE.pm
@@ -801,6 +801,11 @@ sub FindDependencies {
     $self->SUPER::FindDependencies($walker, $deps);
 
     $deps->Add( out => $self->PrincipalObj->Object );
+
+    if ($self->PrincipalObj->Object->Domain eq 'ACLEquivalence') {
+        $deps->Add( out => $self->PrincipalObj->Object->InstanceObj );
+    }
+
     $deps->Add( out => $self->Object );
 }
 

commit 8308c70e16db24da90671a9eb89d875145c25946
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 20:37:31 2017 +0000

    Make sure queue-specific templates get serialized

diff --git a/lib/RT/ObjectScrip.pm b/lib/RT/ObjectScrip.pm
index bfb769a25..ef3a89635 100644
--- a/lib/RT/ObjectScrip.pm
+++ b/lib/RT/ObjectScrip.pm
@@ -269,6 +269,8 @@ sub FindDependencies {
         my $obj = RT::Queue->new( $self->CurrentUser );
         $obj->Load( $self->ObjectId );
         $deps->Add( out => $obj );
+
+        $deps->Add( out => $self->ScripObj->TemplateObj($obj->Id) );
     }
 }
 

commit dbb9fd8519cfbc05e71650a8bb3cf9225171fa74
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 20:58:07 2017 +0000

    Skip tickets ASAP as an optimization

diff --git a/lib/RT/Queue.pm b/lib/RT/Queue.pm
index 0c8a7cdba..6d74f842a 100644
--- a/lib/RT/Queue.pm
+++ b/lib/RT/Queue.pm
@@ -1074,11 +1074,13 @@ sub FindDependencies {
                   VALUE    => 'RT::Queue-' );
     $deps->Add( in => $objs );
 
-    # Tickets
-    $objs = RT::Tickets->new( $self->CurrentUser );
-    $objs->Limit( FIELD => "Queue", VALUE => $self->Id );
-    $objs->{allow_deleted_search} = 1;
-    $deps->Add( in => $objs );
+    # Tickets (skipped early as an optimization)
+    if ($walker->{FollowTickets} || !defined($walker->{FollowTickets})) {
+        $objs = RT::Tickets->new( $self->CurrentUser );
+        $objs->Limit( FIELD => "Queue", VALUE => $self->Id );
+        $objs->{allow_deleted_search} = 1;
+        $deps->Add( in => $objs );
+    }
 
     # Object Custom Roles
     $objs = RT::ObjectCustomRoles->new( $self->CurrentUser );

commit 01e4fdd195707ea3ccc037d000f837c331f400de
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 19:15:08 2017 +0000

    Migrate Storable-related concerns from Serializer to File

diff --git a/lib/RT/Migrate/Serializer.pm b/lib/RT/Migrate/Serializer.pm
index fa30c63c2..50a27b94e 100644
--- a/lib/RT/Migrate/Serializer.pm
+++ b/lib/RT/Migrate/Serializer.pm
@@ -53,7 +53,6 @@ use warnings;
 
 use base 'RT::DependencyWalker';
 
-use Storable qw//;
 sub cmp_version($$) { RT::Handle::cmp_version($_[0],$_[1]) };
 use RT::Migrate::Incremental;
 use RT::Migrate::Serializer::IncrementalRecord;
@@ -327,11 +326,8 @@ sub PushBasics {
 sub InitStream {
     my $self = shift;
 
-    # Write the initial metadata
     my $meta = $self->Metadata;
-    $! = 0;
-    Storable::nstore_fd( $meta, $self->{Filehandle} );
-    die "Failed to write metadata: $!" if $!;
+    $self->WriteMetadata($meta);
 
     return unless cmp_version($meta->{VersionFrom}, $meta->{Version}) < 0;
 
@@ -501,9 +497,7 @@ sub Visit {
         if ($self->{Transform}{"+$class"}) {
             my @extra = $self->{Transform}{"+$class"}->(\%data,\$class);
             for my $e (@extra) {
-                $! = 0;
-                Storable::nstore_fd($e, $self->{Filehandle});
-                die "Failed to write: $!" if $!;
+                $self->WriteRecord($e);
                 $self->{ObjectCount}{$e->[0]}++;
             }
         }
@@ -532,11 +526,7 @@ sub Visit {
         );
     }
 
-    # Write it out; nstore_fd doesn't trap failures to write, so we have
-    # to; by clearing $! and checking it afterwards.
-    $! = 0;
-    Storable::nstore_fd(\@store, $self->{Filehandle});
-    die "Failed to write: $!" if $!;
+    $self->WriteRecord(\@store);
 
     $self->{ObjectCount}{$store[0]}++;
 }
diff --git a/lib/RT/Migrate/Serializer/File.pm b/lib/RT/Migrate/Serializer/File.pm
index 1afc6c6d9..53715eded 100644
--- a/lib/RT/Migrate/Serializer/File.pm
+++ b/lib/RT/Migrate/Serializer/File.pm
@@ -51,6 +51,8 @@ package RT::Migrate::Serializer::File;
 use strict;
 use warnings;
 
+use Storable qw//;
+
 use base 'RT::Migrate::Serializer';
 
 sub Init {
@@ -168,4 +170,23 @@ sub RotateFile {
     $self->OpenFile;
 }
 
+sub WriteMetadata {
+    my $self = shift;
+    my $meta = shift;
+    $! = 0;
+    Storable::nstore_fd( $meta, $self->{Filehandle} );
+    die "Failed to write metadata: $!" if $!;
+}
+
+sub WriteRecord {
+    my $self = shift;
+    my $record = shift;
+
+    # Write it out; nstore_fd doesn't trap failures to write, so we have
+    # to; by clearing $! and checking it afterwards.
+    $! = 0;
+    Storable::nstore_fd($record, $self->{Filehandle});
+    die "Failed to write: $!" if $!;
+}
+
 1;

commit 522fcee95f92f88f237d5fb6bb2b88aec8a5ab72
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 19:33:38 2017 +0000

    First pass at initialdata.json serializer export format

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
new file mode 100644
index 000000000..71964968b
--- /dev/null
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -0,0 +1,176 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::Migrate::Serializer::JSON;
+
+use strict;
+use warnings;
+use JSON qw//;
+
+use base 'RT::Migrate::Serializer';
+
+sub Init {
+    my $self = shift;
+
+    my %args = (
+        Directory   => undef,
+        Force       => undef,
+
+        @_,
+    );
+
+    # Set up the output directory we'll be writing to
+    my ($y,$m,$d) = (localtime)[5,4,3];
+    $args{Directory} = $RT::Organization .
+        sprintf(":%d-%02d-%02d",$y+1900,$m+1,$d)
+        unless defined $args{Directory};
+    system("rm", "-rf", $args{Directory}) if $args{Force};
+    die "Output directory $args{Directory} already exists"
+        if -d $args{Directory};
+    mkdir $args{Directory}
+        or die "Can't create output directory $args{Directory}: $!\n";
+    $self->{Directory} = delete $args{Directory};
+
+    $self->{Records} = {};
+
+    $self->SUPER::Init(@_);
+}
+
+sub Export {
+    my $self = shift;
+
+    # Write the initial metadata
+    $self->InitStream;
+
+    # Walk the objects
+    $self->Walk( @_ );
+
+    # Set up our output file
+    $self->OpenFile;
+
+    # Write out the initialdata
+    $self->WriteFile;
+
+    # Close everything back up
+    $self->CloseFile;
+
+    return $self->ObjectCount;
+}
+
+sub Files {
+    my $self = shift;
+    return ($self->Filename);
+}
+
+sub Filename {
+    my $self = shift;
+    return sprintf(
+        "%s/initialdata.json",
+        $self->{Directory},
+    );
+}
+
+sub Directory {
+    my $self = shift;
+    return $self->{Directory};
+}
+
+sub JSON {
+    my $self = shift;
+    return $self->{JSON} ||= JSON->new->pretty;
+}
+
+sub OpenFile {
+    my $self = shift;
+    open($self->{Filehandle}, ">", $self->Filename)
+        or die "Can't write to file @{[$self->Filename]}: $!";
+}
+
+sub CloseFile {
+    my $self = shift;
+    close($self->{Filehandle})
+        or die "Can't close @{[$self->Filename]}: $!";
+}
+
+sub WriteMetadata {
+    my $self = shift;
+    my $meta = shift;
+
+    # no need to write metadata
+    return;
+}
+
+sub WriteRecord {
+    my $self = shift;
+    my $record = shift;
+
+    my $type = $record->[0];
+    $type =~ s/^RT:://;
+
+    push @{ $self->{Records}{ $type } }, $record->[2];
+}
+
+sub WriteFile {
+    my $self = shift;
+    my %output;
+
+    for my $type (keys %{ $self->{Records} }) {
+        for my $record (@{ $self->{Records}{$type} }) {
+            for my $key (keys %$record) {
+                if (ref($record->{$key}) eq 'SCALAR') {
+                    delete $record->{$key};
+                }
+            }
+            push @{ $output{$type} }, $record;
+        }
+    }
+
+    print { $self->{Filehandle} } $self->JSON->encode(\%output);
+}
+
+1;

commit 66df5d7d897faba40635ab12a204b73fcafb6f21
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 19:58:42 2017 +0000

    Sort JSON keys so its output can be versioned

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 71964968b..ae5cb9228 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -122,7 +122,7 @@ sub Directory {
 
 sub JSON {
     my $self = shift;
-    return $self->{JSON} ||= JSON->new->pretty;
+    return $self->{JSON} ||= JSON->new->pretty->canonical;
 }
 
 sub OpenFile {

commit d6a0f6f8a983c830d2ab3333d949841db75f3826
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:00:21 2017 +0000

    Store records by id
    
    This way we can more easily patch them up later (e.g. canonicalizing
    ObjectScrips to Scrips.Queue)

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index ae5cb9228..f5e711bd5 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -152,7 +152,7 @@ sub WriteRecord {
     my $type = $record->[0];
     $type =~ s/^RT:://;
 
-    push @{ $self->{Records}{ $type } }, $record->[2];
+    $self->{Records}{ $type }{$record->[1]} = $record->[2];
 }
 
 sub WriteFile {
@@ -160,7 +160,8 @@ sub WriteFile {
     my %output;
 
     for my $type (keys %{ $self->{Records} }) {
-        for my $record (@{ $self->{Records}{$type} }) {
+        for my $id (keys %{ $self->{Records}{$type} }) {
+            my $record = $self->{Records}{$type}{$id};
             for my $key (keys %$record) {
                 if (ref($record->{$key}) eq 'SCALAR') {
                     delete $record->{$key};

commit 6f7163d21bcf62ecae1f7abcd8f803b7f51ee75a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:01:11 2017 +0000

    For now, rather than deleting references, dereference them
    
    It's not perfect, but it's better baseline behavior

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index f5e711bd5..6f5d595e7 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -164,7 +164,7 @@ sub WriteFile {
             my $record = $self->{Records}{$type}{$id};
             for my $key (keys %$record) {
                 if (ref($record->{$key}) eq 'SCALAR') {
-                    delete $record->{$key};
+                    $record->{$key} = ${ $record->{$key} };
                 }
             }
             push @{ $output{$type} }, $record;

commit 5a009e56eefd4f0098eedd85216748e41921a92b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:01:50 2017 +0000

    Use initialdata's type names rather than RT class names

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 6f5d595e7..4612fa329 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -155,19 +155,27 @@ sub WriteRecord {
     $self->{Records}{ $type }{$record->[1]} = $record->[2];
 }
 
+my %initialdataType = (
+    ACE => 'ACL',
+    Class => 'Classes',
+    GroupMember => 'Members',
+);
+
 sub WriteFile {
     my $self = shift;
     my %output;
 
-    for my $type (keys %{ $self->{Records} }) {
-        for my $id (keys %{ $self->{Records}{$type} }) {
-            my $record = $self->{Records}{$type}{$id};
+    for my $intype (keys %{ $self->{Records} }) {
+        my $outtype = $initialdataType{$intype} || ($intype . 's');
+
+        for my $id (keys %{ $self->{Records}{$intype} }) {
+            my $record = $self->{Records}{$intype}{$id};
             for my $key (keys %$record) {
                 if (ref($record->{$key}) eq 'SCALAR') {
                     $record->{$key} = ${ $record->{$key} };
                 }
             }
-            push @{ $output{$type} }, $record;
+            push @{ $output{$outtype} }, $record;
         }
     }
 

commit 56f3d4b97c1b5d1cc88b93f654bcab4ca2f6fc11
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:15:43 2017 +0000

    Defer canonicalizing away "RT::" from class names

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 4612fa329..64b0d3d20 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -149,10 +149,7 @@ sub WriteRecord {
     my $self = shift;
     my $record = shift;
 
-    my $type = $record->[0];
-    $type =~ s/^RT:://;
-
-    $self->{Records}{ $type }{$record->[1]} = $record->[2];
+    $self->{Records}{ $record->[0] }{ $record->[1] } = $record->[2];
 }
 
 my %initialdataType = (
@@ -166,7 +163,9 @@ sub WriteFile {
     my %output;
 
     for my $intype (keys %{ $self->{Records} }) {
-        my $outtype = $initialdataType{$intype} || ($intype . 's');
+        my $outtype = $intype;
+        $outtype =~ s/^RT:://;
+        $outtype = $initialdataType{$outtype} || ($outtype . 's');
 
         for my $id (keys %{ $self->{Records}{$intype} }) {
             my $record = $self->{Records}{$intype}{$id};

commit 68a8ac9ca78ee5a0f9bbef493d9c3b18b9c6e1a2
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:15:57 2017 +0000

    Canonicalize references by name
    
    e.g. "Queue: General" instead of "Queue: RT::Queue-example.com-1"

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 64b0d3d20..836fb2b28 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -158,6 +158,17 @@ my %initialdataType = (
     GroupMember => 'Members',
 );
 
+sub CanonicalizeReference {
+    my $self = shift;
+    my $ref = ${ shift(@_) };
+    my $context = shift;
+
+    my ($class, $id) = $ref =~ /^(.*?)-(.*)/
+        or return $ref;
+    my $record = $self->{Records}{$class}{$ref};
+    return $record->{Name} || $ref;
+}
+
 sub WriteFile {
     my $self = shift;
     my %output;
@@ -171,7 +182,7 @@ sub WriteFile {
             my $record = $self->{Records}{$intype}{$id};
             for my $key (keys %$record) {
                 if (ref($record->{$key}) eq 'SCALAR') {
-                    $record->{$key} = ${ $record->{$key} };
+                    $record->{$key} = $self->CanonicalizeReference($record->{$key}, $record);
                 }
             }
             push @{ $output{$outtype} }, $record;

commit 66db10a455312094bf8d9dafc97d36326eba2844
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:49:34 2017 +0000

    Canonicalize OCFs as CF.ApplyTo
    
    We need to take some steps to preserve SortOrder, but initialdata
    ApplyTo doesn't support SortOrder directly

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 836fb2b28..d92622fd1 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -159,9 +159,10 @@ my %initialdataType = (
 );
 
 sub CanonicalizeReference {
-    my $self = shift;
-    my $ref = ${ shift(@_) };
+    my $self    = shift;
+    my $ref     = ${ shift(@_) };
     my $context = shift;
+    my $for_key = shift;
 
     my ($class, $id) = $ref =~ /^(.*?)-(.*)/
         or return $ref;
@@ -169,10 +170,27 @@ sub CanonicalizeReference {
     return $record->{Name} || $ref;
 }
 
+sub CanonicalizeObjects {
+    my $self = shift;
+
+    if (my $OCFs = delete $self->{Records}{'RT::ObjectCustomField'}) {
+        for my $OCF (values %$OCFs) {
+            my $CF = $self->{Records}{'RT::CustomField'}{ ${ $OCF->{CustomField} } };
+            push @{ $CF->{ApplyTo} }, $OCF;
+        }
+
+        for my $CF (values %{ $self->{Records}{'RT::CustomField'} }) {
+            @{ $CF->{ApplyTo} } = map { $_->{ObjectId} } sort { $a->{SortOrder} <=> $b->{SortOrder} } @{ $CF->{ApplyTo} || [] };
+        }
+    }
+}
+
 sub WriteFile {
     my $self = shift;
     my %output;
 
+    $self->CanonicalizeObjects;
+
     for my $intype (keys %{ $self->{Records} }) {
         my $outtype = $intype;
         $outtype =~ s/^RT:://;
@@ -182,7 +200,7 @@ sub WriteFile {
             my $record = $self->{Records}{$intype}{$id};
             for my $key (keys %$record) {
                 if (ref($record->{$key}) eq 'SCALAR') {
-                    $record->{$key} = $self->CanonicalizeReference($record->{$key}, $record);
+                    $record->{$key} = $self->CanonicalizeReference($record->{$key}, $record, $key);
                 }
             }
             push @{ $output{$outtype} }, $record;

commit f89c2196a856899e75c479367f6bc5f2996ac3fb
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 20:58:37 2017 +0000

    Canonicalize ObjectClass into Class.ApplyTo

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index d92622fd1..963c6ed4e 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -170,21 +170,32 @@ sub CanonicalizeReference {
     return $record->{Name} || $ref;
 }
 
-sub CanonicalizeObjects {
+sub _CanonicalizeObjectType {
     my $self = shift;
-
-    if (my $OCFs = delete $self->{Records}{'RT::ObjectCustomField'}) {
-        for my $OCF (values %$OCFs) {
-            my $CF = $self->{Records}{'RT::CustomField'}{ ${ $OCF->{CustomField} } };
-            push @{ $CF->{ApplyTo} }, $OCF;
+    my $object_class = shift;
+    my $object_primary_ref = shift;
+    my $primary_class = shift;
+
+    if (my $objects = delete $self->{Records}{$object_class}) {
+        for my $object (values %$objects) {
+            my $primary = $self->{Records}{$primary_class}{ ${ $object->{$object_primary_ref} } };
+            push @{ $primary->{ApplyTo} }, $object;
         }
 
-        for my $CF (values %{ $self->{Records}{'RT::CustomField'} }) {
-            @{ $CF->{ApplyTo} } = map { $_->{ObjectId} } sort { $a->{SortOrder} <=> $b->{SortOrder} } @{ $CF->{ApplyTo} || [] };
+        for my $primary (values %{ $self->{Records}{$primary_class} }) {
+            @{ $primary->{ApplyTo} } = map { $_->{ObjectId} }
+                                       sort { $a->{SortOrder} <=> $b->{SortOrder} }
+                                       @{ $primary->{ApplyTo} || [] };
         }
     }
 }
 
+sub CanonicalizeObjects {
+    my $self = shift;
+    $self->_CanonicalizeObjectType('RT::ObjectCustomField' => CustomField => 'RT::CustomField');
+    $self->_CanonicalizeObjectType('RT::ObjectClass' => Class => 'RT::Class');
+}
+
 sub WriteFile {
     my $self = shift;
     my %output;

commit ab6de43c9b2ae5598e6c0a530a343cf86a80121b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 21:21:53 2017 +0000

    Factor out _GetRecordByRef

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 963c6ed4e..f436e5a1c 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -158,15 +158,24 @@ my %initialdataType = (
     GroupMember => 'Members',
 );
 
+sub _GetRecordByRef {
+    my $self = shift;
+    my $ref  = shift;
+
+    my ($class) = $ref =~ /^([\w:]+)-/
+        or return undef;
+    return $self->{Records}{$class}{$ref};
+}
+
 sub CanonicalizeReference {
     my $self    = shift;
     my $ref     = ${ shift(@_) };
     my $context = shift;
     my $for_key = shift;
 
-    my ($class, $id) = $ref =~ /^(.*?)-(.*)/
+    my $record = $self->_GetRecordByRef($ref)
         or return $ref;
-    my $record = $self->{Records}{$class}{$ref};
+
     return $record->{Name} || $ref;
 }
 

commit 6024bcc9b548ffb00b790ca5965e58c2a6d28681
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 21:22:28 2017 +0000

    Canonicalize ObjectScrips as Scrips.Queue

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index f436e5a1c..89cfa7eda 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -179,30 +179,64 @@ sub CanonicalizeReference {
     return $record->{Name} || $ref;
 }
 
-sub _CanonicalizeObjectType {
+sub _CanonicalizeManyToMany {
     my $self = shift;
-    my $object_class = shift;
-    my $object_primary_ref = shift;
-    my $primary_class = shift;
+    my %args = (
+        object_class => '',
+        object_primary_ref => '',
+        primary_class => '',
+        primary_key => 'ApplyTo',
+        canonicalize_object => sub { $_->{ObjectId} },
+        @_,
+    );
+
+    my $object_class = $args{object_class};
+    my $object_primary_ref = $args{object_primary_ref};
+    my $primary_class = $args{primary_class};
+    my $primary_key = $args{primary_key};
+    my $canonicalize_object = $args{canonicalize_object};
 
     if (my $objects = delete $self->{Records}{$object_class}) {
         for my $object (values %$objects) {
             my $primary = $self->{Records}{$primary_class}{ ${ $object->{$object_primary_ref} } };
-            push @{ $primary->{ApplyTo} }, $object;
+            push @{ $primary->{$primary_key} }, $object;
         }
 
         for my $primary (values %{ $self->{Records}{$primary_class} }) {
-            @{ $primary->{ApplyTo} } = map { $_->{ObjectId} }
-                                       sort { $a->{SortOrder} <=> $b->{SortOrder} }
-                                       @{ $primary->{ApplyTo} || [] };
+            @{ $primary->{$primary_key} }
+                = map &$canonicalize_object,
+                  sort { $a->{SortOrder} <=> $b->{SortOrder} }
+                  @{ $primary->{$primary_key} || [] };
         }
     }
 }
 
 sub CanonicalizeObjects {
     my $self = shift;
-    $self->_CanonicalizeObjectType('RT::ObjectCustomField' => CustomField => 'RT::CustomField');
-    $self->_CanonicalizeObjectType('RT::ObjectClass' => Class => 'RT::Class');
+
+    $self->_CanonicalizeManyToMany(
+        object_class       => 'RT::ObjectCustomField',
+        object_primary_ref => 'CustomField',
+        primary_class      => 'RT::CustomField',
+    );
+
+    $self->_CanonicalizeManyToMany(
+        object_class       => 'RT::ObjectClass',
+        object_primary_ref => 'Class',
+        primary_class      => 'RT::Class'
+    );
+
+    $self->_CanonicalizeManyToMany(
+        object_class       => 'RT::ObjectScrip',
+        object_primary_ref => 'Scrip',
+        primary_class      => 'RT::Scrip',
+        primary_key        => 'Queue',
+        canonicalize_object => sub {
+            ref($_->{ObjectId})
+                ? $self->_GetRecordByRef(${ $_->{ObjectId} })->{Name}
+                : $_->{ObjectId}
+        },
+    );
 }
 
 sub WriteFile {

commit fe0e99f43e93c003dc1d4ffbca46397ff91a8b1b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 21:33:54 2017 +0000

    Handle ObjectScrip Stage in serializer importer

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 89cfa7eda..9f8343369 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -232,9 +232,10 @@ sub CanonicalizeObjects {
         primary_class      => 'RT::Scrip',
         primary_key        => 'Queue',
         canonicalize_object => sub {
-            ref($_->{ObjectId})
+            my $object = ref($_->{ObjectId})
                 ? $self->_GetRecordByRef(${ $_->{ObjectId} })->{Name}
-                : $_->{ObjectId}
+                : $_->{ObjectId};
+            return { ObjectId => $object, Stage => $_->{Stage} };
         },
     );
 }

commit e350e59b7c19b17dde50226cea57c9bf37205954
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 21:54:07 2017 +0000

    Canonicalize ObjectCustomRole as CustomRole.ApplyTo

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 9f8343369..d5e207cd7 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -223,7 +223,18 @@ sub CanonicalizeObjects {
     $self->_CanonicalizeManyToMany(
         object_class       => 'RT::ObjectClass',
         object_primary_ref => 'Class',
-        primary_class      => 'RT::Class'
+        primary_class      => 'RT::Class',
+    );
+
+    $self->_CanonicalizeManyToMany(
+        object_class       => 'RT::ObjectCustomRole',
+        object_primary_ref => 'CustomRole',
+        primary_class      => 'RT::CustomRole',
+        canonicalize_object => sub {
+            ref($_->{ObjectId})
+                ? $self->_GetRecordByRef(${ $_->{ObjectId} })->{Name}
+                : $_->{ObjectId};
+        },
     );
 
     $self->_CanonicalizeManyToMany(

commit 8f17953f01558ae34739e89632f7609a54d9a4b8
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 17:57:34 2017 +0000

    Avoid serializing attributes (for now)

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index d5e207cd7..105f1d044 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -257,6 +257,8 @@ sub WriteFile {
 
     $self->CanonicalizeObjects;
 
+    delete $self->{Records}{'RT::Attribute'};
+
     for my $intype (keys %{ $self->{Records} }) {
         my $outtype = $intype;
         $outtype =~ s/^RT:://;

commit d33b6ad9e198381142d39154f0a439916393e716
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 17:37:13 2017 +0000

    Only serialize user-defined groups for initialdata
    
    ACLEquivalence, role groups, etc will be created automatically

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 105f1d044..1dcf01faa 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -120,6 +120,23 @@ sub Directory {
     return $self->{Directory};
 }
 
+sub Observe {
+    my $self = shift;
+    my %args = @_;
+
+    my $obj = $args{object};
+
+    # avoid serializing ACLEquivalence, etc
+    if ($obj->isa("RT::Group")) {
+        return 0 unless $obj->Domain eq 'UserDefined';
+    }
+    if ($obj->isa("RT::GroupMember")) {
+        return 0 unless $obj->GroupObj->Object->Domain eq 'UserDefined';
+    }
+
+    return $self->SUPER::Observe(%args);
+}
+
 sub JSON {
     my $self = shift;
     return $self->{JSON} ||= JSON->new->pretty->canonical;

commit 7cb1f345943d4bcfb675bcb4c59b7f90000a0fa2
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:23:49 2017 +0000

    Have _GetRecordByRef dereference if needed

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 1dcf01faa..e417f5a8a 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -179,6 +179,7 @@ sub _GetRecordByRef {
     my $self = shift;
     my $ref  = shift;
 
+    $ref = $$ref if ref($ref) eq 'SCALAR';
     my ($class) = $ref =~ /^([\w:]+)-/
         or return undef;
     return $self->{Records}{$class}{$ref};
@@ -249,7 +250,7 @@ sub CanonicalizeObjects {
         primary_class      => 'RT::CustomRole',
         canonicalize_object => sub {
             ref($_->{ObjectId})
-                ? $self->_GetRecordByRef(${ $_->{ObjectId} })->{Name}
+                ? $self->_GetRecordByRef($_->{ObjectId})->{Name}
                 : $_->{ObjectId};
         },
     );
@@ -261,7 +262,7 @@ sub CanonicalizeObjects {
         primary_key        => 'Queue',
         canonicalize_object => sub {
             my $object = ref($_->{ObjectId})
-                ? $self->_GetRecordByRef(${ $_->{ObjectId} })->{Name}
+                ? $self->_GetRecordByRef($_->{ObjectId})->{Name}
                 : $_->{ObjectId};
             return { ObjectId => $object, Stage => $_->{Stage} };
         },

commit c40f76ca17d0e8ca5cb777ab9ce195163dbbf06b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:26:10 2017 +0000

    Try harder to load references
    
    Even if it's not in the serialized output (perhaps due to being
    excluded) we can still inspect such objects because we have their class
    and id, and the database

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index e417f5a8a..7fc4d4f55 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -180,9 +180,17 @@ sub _GetRecordByRef {
     my $ref  = shift;
 
     $ref = $$ref if ref($ref) eq 'SCALAR';
-    my ($class) = $ref =~ /^([\w:]+)-/
+
+    return RT->System if $ref eq 'RT::System';
+
+    my ($class, $id) = $ref =~ /^([\w:]+)-.*-(\d+)$/
         or return undef;
-    return $self->{Records}{$class}{$ref};
+
+    return $self->{Records}{$class}{$ref} || do {
+        my $obj = $class->new(RT->SystemUser);
+        $obj->Load($id);
+        $obj;
+    };
 }
 
 sub CanonicalizeReference {

commit 45243d74afb3085e98969cbd4b99c24025d2b6c7
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:33:32 2017 +0000

    Canonicalize ACLs the way initialdata needs them

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 7fc4d4f55..ce46e3894 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -237,6 +237,37 @@ sub _CanonicalizeManyToMany {
     }
 }
 
+sub CanonicalizeACLs {
+    my $self = shift;
+
+    for my $ace (values %{ $self->{Records}{'RT::ACE'} }) {
+        my $principal = $self->_GetRecordByRef(delete $ace->{PrincipalId});
+        my $object = $self->_GetRecordByRef(delete $ace->{Object});
+
+        if ($principal->IsGroup) {
+            my $domain = $principal->Object->Domain;
+            if ($domain eq 'ACLEquivalence') {
+                $ace->{UserId} = $principal->Object->InstanceObj->Name;
+            }
+            else {
+                $ace->{GroupDomain} = $domain;
+                if ($domain eq 'SystemInternal') {
+                    $ace->{GroupType} = $principal->Object->Name;
+                }
+                elsif ($domain eq 'RT::Queue-Role') {
+                    $ace->{Queue} = $principal->Object->Instance;
+                }
+            }
+        }
+        else {
+            $ace->{UserId} = $principal->Object->Name;
+        }
+
+        $ace->{ObjectType} = ref($object);
+        $ace->{ObjectId} = $object->Id;
+    }
+}
+
 sub CanonicalizeObjects {
     my $self = shift;
 
@@ -282,6 +313,7 @@ sub WriteFile {
     my %output;
 
     $self->CanonicalizeObjects;
+    $self->CanonicalizeACLs;
 
     delete $self->{Records}{'RT::Attribute'};
 

commit 3db856210d67e9013fe48322250aa08fe8ac696b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:42:07 2017 +0000

    Disambiguate _GetSerializedByRef from _GetObjectByRef
    
    The consuming API is different (serialized hashref vs live object)

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index ce46e3894..faa2892a1 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -175,7 +175,7 @@ my %initialdataType = (
     GroupMember => 'Members',
 );
 
-sub _GetRecordByRef {
+sub _GetObjectByRef {
     my $self = shift;
     my $ref  = shift;
 
@@ -186,11 +186,21 @@ sub _GetRecordByRef {
     my ($class, $id) = $ref =~ /^([\w:]+)-.*-(\d+)$/
         or return undef;
 
-    return $self->{Records}{$class}{$ref} || do {
-        my $obj = $class->new(RT->SystemUser);
-        $obj->Load($id);
-        $obj;
-    };
+    my $obj = $class->new(RT->SystemUser);
+    $obj->Load($id);
+    return $obj;
+}
+
+sub _GetSerializedByRef {
+    my $self = shift;
+    my $ref  = shift;
+
+    $ref = $$ref if ref($ref) eq 'SCALAR';
+
+    my ($class) = $ref =~ /^([\w:]+)-/
+        or return undef;
+
+    return $self->{Records}{$class}{$ref};
 }
 
 sub CanonicalizeReference {
@@ -199,7 +209,7 @@ sub CanonicalizeReference {
     my $context = shift;
     my $for_key = shift;
 
-    my $record = $self->_GetRecordByRef($ref)
+    my $record = $self->_GetSerializedByRef($ref)
         or return $ref;
 
     return $record->{Name} || $ref;
@@ -241,8 +251,8 @@ sub CanonicalizeACLs {
     my $self = shift;
 
     for my $ace (values %{ $self->{Records}{'RT::ACE'} }) {
-        my $principal = $self->_GetRecordByRef(delete $ace->{PrincipalId});
-        my $object = $self->_GetRecordByRef(delete $ace->{Object});
+        my $principal = $self->_GetObjectByRef(delete $ace->{PrincipalId});
+        my $object = $self->_GetObjectByRef(delete $ace->{Object});
 
         if ($principal->IsGroup) {
             my $domain = $principal->Object->Domain;
@@ -289,7 +299,7 @@ sub CanonicalizeObjects {
         primary_class      => 'RT::CustomRole',
         canonicalize_object => sub {
             ref($_->{ObjectId})
-                ? $self->_GetRecordByRef($_->{ObjectId})->{Name}
+                ? $self->_GetSerializedByRef($_->{ObjectId})->{Name}
                 : $_->{ObjectId};
         },
     );
@@ -301,7 +311,7 @@ sub CanonicalizeObjects {
         primary_key        => 'Queue',
         canonicalize_object => sub {
             my $object = ref($_->{ObjectId})
-                ? $self->_GetRecordByRef($_->{ObjectId})->{Name}
+                ? $self->_GetSerializedByRef($_->{ObjectId})->{Name}
                 : $_->{ObjectId};
             return { ObjectId => $object, Stage => $_->{Stage} };
         },

commit 30f78bcbfcf903e95465d1a3a74920bd5fd9b507
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:47:28 2017 +0000

    Handle systemuser

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index faa2892a1..e1c3bb992 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -182,9 +182,10 @@ sub _GetObjectByRef {
     $ref = $$ref if ref($ref) eq 'SCALAR';
 
     return RT->System if $ref eq 'RT::System';
+    return RT->SystemUser if $ref eq 'RT::User-RT_System';
 
     my ($class, $id) = $ref =~ /^([\w:]+)-.*-(\d+)$/
-        or return undef;
+        or do { warn "Unable to canonicalize ref '$ref'"; return undef };
 
     my $obj = $class->new(RT->SystemUser);
     $obj->Load($id);

commit 6ebadd606e7d1ac9d6ef68ee7bed245b92e2bf2c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:47:34 2017 +0000

    Creator and LastUpdatedBy are always user ids

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index e1c3bb992..c33c2e3fd 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -210,6 +210,10 @@ sub CanonicalizeReference {
     my $context = shift;
     my $for_key = shift;
 
+    if ($for_key eq 'Creator' || $for_key eq 'LastUpdatedBy') {
+        return $self->_GetObjectByRef($ref)->Id;
+    }
+
     my $record = $self->_GetSerializedByRef($ref)
         or return $ref;
 

commit 2b8d4b9cc288458cbf2344119b78b4f371de61f1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 18:47:53 2017 +0000

    Avoid uniqueness violations (for now) by skipping id from initialdata output
    
    This will likely be added in to handle updating existing records

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index c33c2e3fd..0d3a3555b 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -344,6 +344,7 @@ sub WriteFile {
                     $record->{$key} = $self->CanonicalizeReference($record->{$key}, $record, $key);
                 }
             }
+            delete $record->{id};
             push @{ $output{$outtype} }, $record;
         }
     }

commit 67663b9500c120e6c4fdba1c0fb9e870869fc41e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 19:01:41 2017 +0000

    CanonicalizeUsers
    
    Skip principal fields, add Privileged

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 0d3a3555b..b93478735 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -283,6 +283,20 @@ sub CanonicalizeACLs {
     }
 }
 
+sub CanonicalizeUsers {
+    my $self = shift;
+
+    for my $user (values %{ $self->{Records}{'RT::User'} }) {
+        delete $user->{Principal};
+        delete $user->{PrincipalId};
+
+        my $object = RT::User->new(RT->SystemUser);
+        $object->Load($user->{id});
+
+        $user->{Privileged} = $object->Privileged ? JSON::true : JSON::false;
+    }
+}
+
 sub CanonicalizeObjects {
     my $self = shift;
 
@@ -329,6 +343,7 @@ sub WriteFile {
 
     $self->CanonicalizeObjects;
     $self->CanonicalizeACLs;
+    $self->CanonicalizeUsers;
 
     delete $self->{Records}{'RT::Attribute'};
 

commit 6f166613e974a80737cfc68a0f14ddeeb3c7926a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 21:19:01 2017 +0000

    Fix serialized OCFs for queues

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index b93478735..a9dee2133 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -301,9 +301,14 @@ sub CanonicalizeObjects {
     my $self = shift;
 
     $self->_CanonicalizeManyToMany(
-        object_class       => 'RT::ObjectCustomField',
-        object_primary_ref => 'CustomField',
-        primary_class      => 'RT::CustomField',
+        object_class        => 'RT::ObjectCustomField',
+        object_primary_ref  => 'CustomField',
+        primary_class       => 'RT::CustomField',
+        canonicalize_object => sub {
+            ref($_->{ObjectId})
+                ? $self->_GetSerializedByRef($_->{ObjectId})->{Name}
+                : $_->{ObjectId};
+        },
     );
 
     $self->_CanonicalizeManyToMany(

commit 6bb58b4f926ceca864d5edc4f7471d8ffb66720c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 21:30:44 2017 +0000

    Roundtrip CFVs

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index a9dee2133..5d5bcdd5b 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -311,6 +311,18 @@ sub CanonicalizeObjects {
         },
     );
 
+    $self->_CanonicalizeManyToMany(
+        object_class        => 'RT::CustomFieldValue',
+        object_primary_ref  => 'CustomField',
+        primary_class       => 'RT::CustomField',
+        primary_key         => 'Values',
+        canonicalize_object => sub {
+            my %object = %$_;
+            delete @object{qw/id CustomField Created LastUpdated Creator LastUpdatedBy/};
+            return \%object;
+        },
+    );
+
     $self->_CanonicalizeManyToMany(
         object_class       => 'RT::ObjectClass',
         object_primary_ref => 'Class',

commit ab3d464965a4e620679c0a28a9b443ff9ca05d56
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 22:07:02 2017 +0000

    Roundtrip group members

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 5d5bcdd5b..213d76fd8 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -297,6 +297,21 @@ sub CanonicalizeUsers {
     }
 }
 
+sub CanonicalizeGroupMembers {
+    my $self = shift;
+
+    for my $record (values %{ $self->{Records}{'RT::GroupMember'} }) {
+        my $group = $self->_GetObjectByRef(delete $record->{GroupId});
+        $record->{Group} = $group->Object->Name;
+
+        my $member = $self->_GetObjectByRef(delete $record->{MemberId});
+        $record->{Class} = ref($member->Object);
+        $record->{Name} = $member->Object->Name;
+
+        delete @$record{qw/Creator Created LastUpdated LastUpdatedBy/};
+    }
+}
+
 sub CanonicalizeObjects {
     my $self = shift;
 
@@ -361,6 +376,7 @@ sub WriteFile {
     $self->CanonicalizeObjects;
     $self->CanonicalizeACLs;
     $self->CanonicalizeUsers;
+    $self->CanonicalizeGroupMembers;
 
     delete $self->{Records}{'RT::Attribute'};
 

commit 7e21d67a7dd814693fa5a76e7b717a75911f4de1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 00:31:27 2017 +0000

    Fixing up ObjectScrip sort order

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 213d76fd8..928dd97b1 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -361,10 +361,11 @@ sub CanonicalizeObjects {
         primary_class      => 'RT::Scrip',
         primary_key        => 'Queue',
         canonicalize_object => sub {
-            my $object = ref($_->{ObjectId})
-                ? $self->_GetSerializedByRef($_->{ObjectId})->{Name}
-                : $_->{ObjectId};
-            return { ObjectId => $object, Stage => $_->{Stage} };
+            my %object = %$_;
+            delete @object{qw/id Scrip Created LastUpdated Creator LastUpdatedBy/};
+            $object{ObjectId} = $self->_GetSerializedByRef($object{ObjectId})->{Name}
+                if $object{ObjectId}; # 0 meaning Global can stay 0
+            return \%object;
         },
     );
 }

commit 98b5f0bc117054af62b9aaa984c840fcf2b0e7e3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 01:09:44 2017 +0000

    Round-trip unapplied scrips using a NoAutoGlobal key

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 928dd97b1..1bfc38d14 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -137,6 +137,15 @@ sub Observe {
     return $self->SUPER::Observe(%args);
 }
 
+sub PushBasics {
+    my $self = shift;
+    $self->SUPER::PushBasics(@_);
+
+    # we want to include all CFs, scrips, etc, not just the reachable ones
+    $self->PushCollections(qw(CustomFields CustomRoles));
+    $self->PushCollections(qw(Scrips)) if $self->{FollowScrips};
+}
+
 sub JSON {
     my $self = shift;
     return $self->{JSON} ||= JSON->new->pretty->canonical;
@@ -227,6 +236,7 @@ sub _CanonicalizeManyToMany {
         object_primary_ref => '',
         primary_class => '',
         primary_key => 'ApplyTo',
+        add_to_primary => undef,
         canonicalize_object => sub { $_->{ObjectId} },
         @_,
     );
@@ -235,6 +245,7 @@ sub _CanonicalizeManyToMany {
     my $object_primary_ref = $args{object_primary_ref};
     my $primary_class = $args{primary_class};
     my $primary_key = $args{primary_key};
+    my %add_to_primary = %{ $args{add_to_primary} || {} };
     my $canonicalize_object = $args{canonicalize_object};
 
     if (my $objects = delete $self->{Records}{$object_class}) {
@@ -248,6 +259,8 @@ sub _CanonicalizeManyToMany {
                 = map &$canonicalize_object,
                   sort { $a->{SortOrder} <=> $b->{SortOrder} }
                   @{ $primary->{$primary_key} || [] };
+
+            %$primary = (%$primary, %add_to_primary);
         }
     }
 }
@@ -356,10 +369,11 @@ sub CanonicalizeObjects {
     );
 
     $self->_CanonicalizeManyToMany(
-        object_class       => 'RT::ObjectScrip',
-        object_primary_ref => 'Scrip',
-        primary_class      => 'RT::Scrip',
-        primary_key        => 'Queue',
+        object_class        => 'RT::ObjectScrip',
+        object_primary_ref  => 'Scrip',
+        primary_class       => 'RT::Scrip',
+        primary_key         => 'Queue',
+        add_to_primary      => { NoAutoGlobal => 1 },
         canonicalize_object => sub {
             my %object = %$_;
             delete @object{qw/id Scrip Created LastUpdated Creator LastUpdatedBy/};

commit ce4bb242ff62d702dc365e72cb1db3f0c274dc6d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 16:21:51 2017 +0000

    Export OCFVs as CustomFields key on object for initialdata

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 1bfc38d14..48bf3fbd3 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -325,6 +325,26 @@ sub CanonicalizeGroupMembers {
     }
 }
 
+sub CanonicalizeObjectCustomFieldValues {
+    my $self = shift;
+
+    for my $record (values %{ $self->{Records}{'RT::ObjectCustomFieldValue'} }) {
+        my $object = $self->_GetSerializedByRef(delete $record->{Object});
+
+        my $cf = $self->_GetSerializedByRef(delete $record->{CustomField});
+        $record->{CustomField} = $cf->{Name};
+
+        delete @$record{qw/id Created Creator LastUpdated LastUpdatedBy/};
+
+        $record->{Content} = $record->{LargeContent} if $record->{LargeContent};
+        delete $record->{LargeContent};
+
+        push @{ $object->{CustomFields} }, $record;
+    }
+
+    delete $self->{Records}{'RT::ObjectCustomFieldValue'};
+}
+
 sub CanonicalizeObjects {
     my $self = shift;
 
@@ -389,6 +409,7 @@ sub WriteFile {
     my %output;
 
     $self->CanonicalizeObjects;
+    $self->CanonicalizeObjectCustomFieldValues;
     $self->CanonicalizeACLs;
     $self->CanonicalizeUsers;
     $self->CanonicalizeGroupMembers;

commit a7dbcdf24bdc983c94d134071be473c6f8e95eab
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 16:35:35 2017 +0000

    No need to rewrite LargeContent as Content

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 48bf3fbd3..455c6e551 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -336,9 +336,6 @@ sub CanonicalizeObjectCustomFieldValues {
 
         delete @$record{qw/id Created Creator LastUpdated LastUpdatedBy/};
 
-        $record->{Content} = $record->{LargeContent} if $record->{LargeContent};
-        delete $record->{LargeContent};
-
         push @{ $object->{CustomFields} }, $record;
     }
 

commit 2081fc158c9937fc83a050ef2e817f5b7e5c2152
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 16:42:35 2017 +0000

    Remove create/update metadata on initialdata export
    
    RT doesn't handle it well on import, and we also don't export/import
    transactions through initialdata anyway.
    
    Better to let create/update reflect when the records in this RT instance
    were actually created/updated and by whom, rather than whichever RT
    instance happened to have exported the initialdata (which may have a
    completely different set of users anyway!)

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 455c6e551..947d0a720 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -219,10 +219,6 @@ sub CanonicalizeReference {
     my $context = shift;
     my $for_key = shift;
 
-    if ($for_key eq 'Creator' || $for_key eq 'LastUpdatedBy') {
-        return $self->_GetObjectByRef($ref)->Id;
-    }
-
     my $record = $self->_GetSerializedByRef($ref)
         or return $ref;
 
@@ -320,8 +316,6 @@ sub CanonicalizeGroupMembers {
         my $member = $self->_GetObjectByRef(delete $record->{MemberId});
         $record->{Class} = ref($member->Object);
         $record->{Name} = $member->Object->Name;
-
-        delete @$record{qw/Creator Created LastUpdated LastUpdatedBy/};
     }
 }
 
@@ -334,7 +328,7 @@ sub CanonicalizeObjectCustomFieldValues {
         my $cf = $self->_GetSerializedByRef(delete $record->{CustomField});
         $record->{CustomField} = $cf->{Name};
 
-        delete @$record{qw/id Created Creator LastUpdated LastUpdatedBy/};
+        delete @$record{qw/id/};
 
         push @{ $object->{CustomFields} }, $record;
     }
@@ -363,7 +357,7 @@ sub CanonicalizeObjects {
         primary_key         => 'Values',
         canonicalize_object => sub {
             my %object = %$_;
-            delete @object{qw/id CustomField Created LastUpdated Creator LastUpdatedBy/};
+            delete @object{qw/id CustomField/};
             return \%object;
         },
     );
@@ -393,7 +387,7 @@ sub CanonicalizeObjects {
         add_to_primary      => { NoAutoGlobal => 1 },
         canonicalize_object => sub {
             my %object = %$_;
-            delete @object{qw/id Scrip Created LastUpdated Creator LastUpdatedBy/};
+            delete @object{qw/id Scrip/};
             $object{ObjectId} = $self->_GetSerializedByRef($object{ObjectId})->{Name}
                 if $object{ObjectId}; # 0 meaning Global can stay 0
             return \%object;
@@ -405,6 +399,10 @@ sub WriteFile {
     my $self = shift;
     my %output;
 
+    for my $record (map { values %$_ } values %{ $self->{Records} }) {
+        delete @$record{qw/Creator Created LastUpdated LastUpdatedBy/};
+    }
+
     $self->CanonicalizeObjects;
     $self->CanonicalizeObjectCustomFieldValues;
     $self->CanonicalizeACLs;

commit a0bbc30b34281e05d4b8988f9b6571d90a5d0319
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 16:46:25 2017 +0000

    Remove spurious Principal and PrincipalId fields on Groups

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 947d0a720..3396cc4cc 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -306,6 +306,15 @@ sub CanonicalizeUsers {
     }
 }
 
+sub CanonicalizeGroups {
+    my $self = shift;
+
+    for my $group (values %{ $self->{Records}{'RT::Group'} }) {
+        delete $group->{Principal};
+        delete $group->{PrincipalId};
+    }
+}
+
 sub CanonicalizeGroupMembers {
     my $self = shift;
 
@@ -407,6 +416,7 @@ sub WriteFile {
     $self->CanonicalizeObjectCustomFieldValues;
     $self->CanonicalizeACLs;
     $self->CanonicalizeUsers;
+    $self->CanonicalizeGroups;
     $self->CanonicalizeGroupMembers;
 
     delete $self->{Records}{'RT::Attribute'};

commit 64113d4162214ff77a62f9c4852bf9f7422e9077
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:00:56 2017 +0000

    Remove spurious NoAutoGlobal

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 3396cc4cc..d756ff475 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -241,7 +241,7 @@ sub _CanonicalizeManyToMany {
     my $object_primary_ref = $args{object_primary_ref};
     my $primary_class = $args{primary_class};
     my $primary_key = $args{primary_key};
-    my %add_to_primary = %{ $args{add_to_primary} || {} };
+    my $add_to_primary = $args{add_to_primary};
     my $canonicalize_object = $args{canonicalize_object};
 
     if (my $objects = delete $self->{Records}{$object_class}) {
@@ -256,7 +256,9 @@ sub _CanonicalizeManyToMany {
                   sort { $a->{SortOrder} <=> $b->{SortOrder} }
                   @{ $primary->{$primary_key} || [] };
 
-            %$primary = (%$primary, %add_to_primary);
+            if (ref($add_to_primary) eq 'CODE') {
+                $add_to_primary->($primary);
+            }
         }
     }
 }
@@ -393,7 +395,10 @@ sub CanonicalizeObjects {
         object_primary_ref  => 'Scrip',
         primary_class       => 'RT::Scrip',
         primary_key         => 'Queue',
-        add_to_primary      => { NoAutoGlobal => 1 },
+        add_to_primary      => sub {
+            my $primary = shift;
+            $primary->{NoAutoGlobal} = 1 if @{ $primary->{Queue} || [] } == 0;
+        },
         canonicalize_object => sub {
             my %object = %$_;
             delete @object{qw/id Scrip/};

commit 80f8a8b008f5dc933570e1fc07e61b6d2c33085a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:01:19 2017 +0000

    ACLs without object type are for RT::System

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index d756ff475..8f55ae051 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -289,8 +289,10 @@ sub CanonicalizeACLs {
             $ace->{UserId} = $principal->Object->Name;
         }
 
-        $ace->{ObjectType} = ref($object);
-        $ace->{ObjectId} = $object->Id;
+        unless ($object->isa('RT::System')) {
+            $ace->{ObjectType} = ref($object);
+            $ace->{ObjectId} = $object->Id;
+        }
     }
 }
 

commit 7273202005d70d5f8487f89932bd09dd1ce47ed3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:02:40 2017 +0000

    ACL PrincipalType is implicit
    
    Furthermore we were exporting UserId => '...', PrincipalType => 'Group'

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 8f55ae051..d4d80af99 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -267,6 +267,7 @@ sub CanonicalizeACLs {
     my $self = shift;
 
     for my $ace (values %{ $self->{Records}{'RT::ACE'} }) {
+        delete $ace->{PrincipalType};
         my $principal = $self->_GetObjectByRef(delete $ace->{PrincipalId});
         my $object = $self->_GetObjectByRef(delete $ace->{Object});
 

commit cb83c6b79eec302fa83f1d7a7a3d7d144731e77a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:17:39 2017 +0000

    Instance doesn't make sense for UserDefined or SystemInternal groups

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index d4d80af99..1dce59311 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -317,6 +317,9 @@ sub CanonicalizeGroups {
     for my $group (values %{ $self->{Records}{'RT::Group'} }) {
         delete $group->{Principal};
         delete $group->{PrincipalId};
+
+        delete $group->{Instance} if $group->{Domain} eq 'UserDefined'
+                                  || $group->{Domain} eq 'SystemInternal';
     }
 }
 

commit 2e1a00b3c60a9f2462950ef5d8d87ca93d00a1b2
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:17:52 2017 +0000

    Avoid serializing some empty keys for CFs

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 1dce59311..7f5ba4d48 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -336,6 +336,15 @@ sub CanonicalizeGroupMembers {
     }
 }
 
+sub CanonicalizeCustomFields {
+    my $self = shift;
+
+    for my $record (values %{ $self->{Records}{'RT::CustomField'} }) {
+        delete $record->{Pattern} if $record->{Pattern} eq "";
+        delete $record->{UniqueValues} if !$record->{UniqueValues};
+    }
+}
+
 sub CanonicalizeObjectCustomFieldValues {
     my $self = shift;
 
@@ -429,6 +438,7 @@ sub WriteFile {
     $self->CanonicalizeUsers;
     $self->CanonicalizeGroups;
     $self->CanonicalizeGroupMembers;
+    $self->CanonicalizeCustomFields;
 
     delete $self->{Records}{'RT::Attribute'};
 

commit 9c9d6e06b3f0527e81b7f247aec244fd1b277208
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:18:18 2017 +0000

    Avoid serializing Disabled:0 for all objects

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 7f5ba4d48..4de4eea4e 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -455,6 +455,8 @@ sub WriteFile {
                 }
             }
             delete $record->{id};
+            delete $record->{Disabled} if !$record->{Disabled};
+
             push @{ $output{$outtype} }, $record;
         }
     }

commit 2b0a32ebd6847cbc547e8b17539cdc8cc656e2dd
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:18:34 2017 +0000

    Avoid exporting crucial system objects like RT_System user

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 4de4eea4e..a248fe589 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -424,6 +424,29 @@ sub CanonicalizeObjects {
     );
 }
 
+# Exclude critical system objects that should already be present in the importing side
+sub ShouldExcludeObject {
+    my $self = shift;
+    my $class = shift;
+    my $id = shift;
+    my $record = shift;
+
+    return 1 if $class eq 'RT::User'
+             && ($record->{Name} eq 'RT_System' || $record->{Name} eq 'Nobody');
+
+    return 1 if $class eq 'RT::ACE'
+             && ((($record->{UserId}||'') eq 'Nobody' && $record->{RightName} eq 'OwnTicket')
+             || (($record->{UserId}||'') eq 'RT_System' && $record->{RightName} eq 'SuperUser'));
+
+    return 1 if $class eq 'RT::Group'
+             && ($record->{Domain} eq 'RT::System-Role' || $record->{Domain} eq 'SystemInternal');
+
+    return 1 if $class eq 'RT::Queue'
+             && $record->{Name} eq '___Approvals';
+
+    return 0;
+}
+
 sub WriteFile {
     my $self = shift;
     my %output;
@@ -449,6 +472,9 @@ sub WriteFile {
 
         for my $id (keys %{ $self->{Records}{$intype} }) {
             my $record = $self->{Records}{$intype}{$id};
+
+            next if $self->ShouldExcludeObject($intype, $id, $record);
+
             for my $key (keys %$record) {
                 if (ref($record->{$key}) eq 'SCALAR') {
                     $record->{$key} = $self->CanonicalizeReference($record->{$key}, $record, $key);

commit 779787d31c45034c08adebe8902d4f8490807907
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:36:12 2017 +0000

    Improve export of group membership

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index a248fe589..1320e5985 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -126,12 +126,14 @@ sub Observe {
 
     my $obj = $args{object};
 
-    # avoid serializing ACLEquivalence, etc
     if ($obj->isa("RT::Group")) {
-        return 0 unless $obj->Domain eq 'UserDefined';
+        return 0 if $obj->Domain eq 'ACLEquivalence'
+                 || $obj->Domain =~ /^RT::(Queue|Catalog)-Role$/;
     }
     if ($obj->isa("RT::GroupMember")) {
-        return 0 unless $obj->GroupObj->Object->Domain eq 'UserDefined';
+        my $domain = $obj->GroupObj->Object->Domain;
+        return 0 if $domain eq 'ACLEquivalence'
+                 || $domain eq 'SystemInternal';
     }
 
     return $self->SUPER::Observe(%args);
@@ -329,6 +331,8 @@ sub CanonicalizeGroupMembers {
     for my $record (values %{ $self->{Records}{'RT::GroupMember'} }) {
         my $group = $self->_GetObjectByRef(delete $record->{GroupId});
         $record->{Group} = $group->Object->Name;
+        $record->{GroupDomain} = $group->Object->Domain
+            unless $group->Object->Domain eq 'UserDefined';
 
         my $member = $self->_GetObjectByRef(delete $record->{MemberId});
         $record->{Class} = ref($member->Object);
@@ -444,6 +448,10 @@ sub ShouldExcludeObject {
     return 1 if $class eq 'RT::Queue'
              && $record->{Name} eq '___Approvals';
 
+    return 1 if $class eq 'RT::GroupMember'
+             && $record->{Group} eq 'Owner' && $record->{GroupDomain} eq 'RT::System-Role'
+             && $record->{Class} eq 'RT::User' && $record->{Name} eq 'Nobody';
+
     return 0;
 }
 

commit c54abcd5c2a9432e6ab18b6a7edd761b5abae52a
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:39:46 2017 +0000

    Refactor ShouldExcludeObject for clarity

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 1320e5985..9d02f472e 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -435,22 +435,27 @@ sub ShouldExcludeObject {
     my $id = shift;
     my $record = shift;
 
-    return 1 if $class eq 'RT::User'
-             && ($record->{Name} eq 'RT_System' || $record->{Name} eq 'Nobody');
-
-    return 1 if $class eq 'RT::ACE'
-             && ((($record->{UserId}||'') eq 'Nobody' && $record->{RightName} eq 'OwnTicket')
-             || (($record->{UserId}||'') eq 'RT_System' && $record->{RightName} eq 'SuperUser'));
-
-    return 1 if $class eq 'RT::Group'
-             && ($record->{Domain} eq 'RT::System-Role' || $record->{Domain} eq 'SystemInternal');
-
-    return 1 if $class eq 'RT::Queue'
-             && $record->{Name} eq '___Approvals';
-
-    return 1 if $class eq 'RT::GroupMember'
-             && $record->{Group} eq 'Owner' && $record->{GroupDomain} eq 'RT::System-Role'
-             && $record->{Class} eq 'RT::User' && $record->{Name} eq 'Nobody';
+    if ($class eq 'RT::User') {
+        return 1 if $record->{Name} eq 'RT_System'
+                 || $record->{Name} eq 'Nobody';
+    }
+    elsif ($class eq 'RT::ACE') {
+        return 1 if ($record->{UserId}||'') eq 'Nobody' && $record->{RightName} eq 'OwnTicket';
+        return 1 if ($record->{UserId}||'') eq 'RT_System' && $record->{RightName} eq 'SuperUser';
+    }
+    elsif ($class eq 'RT::Group') {
+        return 1 if $record->{Domain} eq 'RT::System-Role'
+                 || $record->{Domain} eq 'SystemInternal';
+    }
+    elsif ($class eq 'RT::Queue') {
+        return 1 if $record->{Name} eq '___Approvals';
+    }
+    elsif ($class eq 'RT::GroupMember') {
+        return 1 if $record->{Group} eq 'Owner'
+                 && $record->{GroupDomain} eq 'RT::System-Role'
+                 && $record->{Class} eq 'RT::User'
+                 && $record->{Name} eq 'Nobody';
+    }
 
     return 0;
 }

commit 751e44ab931082f05265a6e76db79d7b66633341
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 19:53:40 2017 +0000

    Serialize role members but not role groups

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 9d02f472e..2eb5532f3 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -127,8 +127,7 @@ sub Observe {
     my $obj = $args{object};
 
     if ($obj->isa("RT::Group")) {
-        return 0 if $obj->Domain eq 'ACLEquivalence'
-                 || $obj->Domain =~ /^RT::(Queue|Catalog)-Role$/;
+        return 0 if $obj->Domain eq 'ACLEquivalence';
     }
     if ($obj->isa("RT::GroupMember")) {
         my $domain = $obj->GroupObj->Object->Domain;
@@ -316,7 +315,17 @@ sub CanonicalizeUsers {
 sub CanonicalizeGroups {
     my $self = shift;
 
-    for my $group (values %{ $self->{Records}{'RT::Group'} }) {
+    for my $id (keys %{ $self->{Records}{'RT::Group'} }) {
+        my $group = $self->{Records}{'RT::Group'}{$id};
+
+        # no need to serialize this because role groups are automatically
+        # created; but we can't exclude this in ->Observe because then we
+        # lose out on the group members
+        if ($group->{Domain} =~ /-Role$/) {
+            delete $self->{Records}{'RT::Group'}{$id};
+            next;
+        }
+
         delete $group->{Principal};
         delete $group->{PrincipalId};
 
@@ -452,7 +461,7 @@ sub ShouldExcludeObject {
     }
     elsif ($class eq 'RT::GroupMember') {
         return 1 if $record->{Group} eq 'Owner'
-                 && $record->{GroupDomain} eq 'RT::System-Role'
+                 && $record->{GroupDomain} =~ /-Role$/
                  && $record->{Class} eq 'RT::User'
                  && $record->{Name} eq 'Nobody';
     }

commit 0798a710c87b232837128fad75d2c7957fc7bd82
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 20:00:29 2017 +0000

    Support export of queue watcher groups

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 2eb5532f3..04a7b0f2d 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -338,14 +338,18 @@ sub CanonicalizeGroupMembers {
     my $self = shift;
 
     for my $record (values %{ $self->{Records}{'RT::GroupMember'} }) {
-        my $group = $self->_GetObjectByRef(delete $record->{GroupId});
-        $record->{Group} = $group->Object->Name;
-        $record->{GroupDomain} = $group->Object->Domain
-            unless $group->Object->Domain eq 'UserDefined';
-
-        my $member = $self->_GetObjectByRef(delete $record->{MemberId});
-        $record->{Class} = ref($member->Object);
-        $record->{Name} = $member->Object->Name;
+        my $group = $self->_GetObjectByRef(delete $record->{GroupId})->Object;
+        my $domain = $group->Domain;
+
+        $record->{Group} = $group->Name;
+        $record->{GroupDomain} = $domain
+            unless $domain eq 'UserDefined';
+        $record->{GroupInstance} = \($group->InstanceObj->UID)
+            if $domain =~ /-Role$/;
+
+        my $member = $self->_GetObjectByRef(delete $record->{MemberId})->Object;
+        $record->{Class} = ref($member);
+        $record->{Name} = $member->Name;
     }
 }
 

commit 3b1efa0e0323762223625e0eb5540f90964ee141
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 20:18:32 2017 +0000

    Handle group, role, and user ACLs

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 04a7b0f2d..e37632488 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -273,17 +273,18 @@ sub CanonicalizeACLs {
         my $object = $self->_GetObjectByRef(delete $ace->{Object});
 
         if ($principal->IsGroup) {
-            my $domain = $principal->Object->Domain;
+            my $group = $principal->Object;
+            my $domain = $group->Domain;
             if ($domain eq 'ACLEquivalence') {
-                $ace->{UserId} = $principal->Object->InstanceObj->Name;
+                $ace->{UserId} = $group->InstanceObj->Name;
             }
             else {
                 $ace->{GroupDomain} = $domain;
-                if ($domain eq 'SystemInternal') {
-                    $ace->{GroupType} = $principal->Object->Name;
+                if ($domain eq 'UserDefined') {
+                    $ace->{GroupId} = $group->Name;
                 }
-                elsif ($domain eq 'RT::Queue-Role') {
-                    $ace->{Queue} = $principal->Object->Instance;
+                if ($domain eq 'SystemInternal' || $domain =~ /-Role$/) {
+                    $ace->{GroupType} = $group->Name;
                 }
             }
         }
@@ -293,7 +294,7 @@ sub CanonicalizeACLs {
 
         unless ($object->isa('RT::System')) {
             $ace->{ObjectType} = ref($object);
-            $ace->{ObjectId} = $object->Id;
+            $ace->{ObjectId} = \($object->UID);
         }
     }
 }

commit 18efb3178d5d5be1d126ae660b32a8c10f776f33
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 21 21:40:34 2017 +0000

    Round-trip articles

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index e37632488..d15e9558d 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -380,6 +380,14 @@ sub CanonicalizeObjectCustomFieldValues {
     delete $self->{Records}{'RT::ObjectCustomFieldValue'};
 }
 
+sub CanonicalizeArticles {
+    my $self = shift;
+
+    for my $record (values %{ $self->{Records}{'RT::Article'} }) {
+        delete $record->{URI};
+    }
+}
+
 sub CanonicalizeObjects {
     my $self = shift;
 
@@ -489,6 +497,7 @@ sub WriteFile {
     $self->CanonicalizeGroups;
     $self->CanonicalizeGroupMembers;
     $self->CanonicalizeCustomFields;
+    $self->CanonicalizeArticles;
 
     delete $self->{Records}{'RT::Attribute'};
 

commit e4b810ecced0d1c2b984231481794c41dd5b34ee
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 18:15:46 2017 +0000

    Avoid throwing errors on disabled many-to-many relationships

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index d15e9558d..a393e6f7c 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -253,7 +253,8 @@ sub _CanonicalizeManyToMany {
 
         for my $primary (values %{ $self->{Records}{$primary_class} }) {
             @{ $primary->{$primary_key} }
-                = map &$canonicalize_object,
+                = grep defined,
+                  map &$canonicalize_object,
                   sort { $a->{SortOrder} <=> $b->{SortOrder} }
                   @{ $primary->{$primary_key} || [] };
 
@@ -396,9 +397,10 @@ sub CanonicalizeObjects {
         object_primary_ref  => 'CustomField',
         primary_class       => 'RT::CustomField',
         canonicalize_object => sub {
-            ref($_->{ObjectId})
-                ? $self->_GetSerializedByRef($_->{ObjectId})->{Name}
-                : $_->{ObjectId};
+            my $id = $_->{ObjectId};
+            return $id if !ref($id);
+            my $serialized = $self->_GetSerializedByRef($id);
+            return $serialized ? $serialized->{Name} : undef;
         },
     );
 
@@ -418,6 +420,12 @@ sub CanonicalizeObjects {
         object_class       => 'RT::ObjectClass',
         object_primary_ref => 'Class',
         primary_class      => 'RT::Class',
+        canonicalize_object => sub {
+            my $id = $_->{ObjectId};
+            return $id if !ref($id);
+            my $serialized = $self->_GetSerializedByRef($id);
+            return $serialized ? $serialized->{Name} : undef;
+        },
     );
 
     $self->_CanonicalizeManyToMany(
@@ -425,9 +433,10 @@ sub CanonicalizeObjects {
         object_primary_ref => 'CustomRole',
         primary_class      => 'RT::CustomRole',
         canonicalize_object => sub {
-            ref($_->{ObjectId})
-                ? $self->_GetSerializedByRef($_->{ObjectId})->{Name}
-                : $_->{ObjectId};
+            my $id = $_->{ObjectId};
+            return $id if !ref($id);
+            my $serialized = $self->_GetSerializedByRef($id);
+            return $serialized ? $serialized->{Name} : undef;
         },
     );
 
@@ -443,8 +452,13 @@ sub CanonicalizeObjects {
         canonicalize_object => sub {
             my %object = %$_;
             delete @object{qw/id Scrip/};
-            $object{ObjectId} = $self->_GetSerializedByRef($object{ObjectId})->{Name}
-                if $object{ObjectId}; # 0 meaning Global can stay 0
+
+            if (ref($_->{ObjectId})) {
+                my $serialized = $self->_GetSerializedByRef($_->{ObjectId});
+                return undef if !$serialized;
+                $object{ObjectId} = $serialized->{Name};
+            }
+
             return \%object;
         },
     );

commit e4672182df5c0d7b0c204e31d7c690d2a2cb7fdf
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 19:42:21 2017 +0000

    Avoid undef warnings on CFs without a pattern

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index a393e6f7c..fa262e653 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -359,7 +359,7 @@ sub CanonicalizeCustomFields {
     my $self = shift;
 
     for my $record (values %{ $self->{Records}{'RT::CustomField'} }) {
-        delete $record->{Pattern} if $record->{Pattern} eq "";
+        delete $record->{Pattern} if ($record->{Pattern}||'') eq "";
         delete $record->{UniqueValues} if !$record->{UniqueValues};
     }
 }

commit 4ca88f8d4f8243b37568bc4dd9adafd575308361
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 19:58:59 2017 +0000

    Skip serializing OCFVs on live objects for disabled CFs

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index fa262e653..8a4415a4c 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -371,6 +371,7 @@ sub CanonicalizeObjectCustomFieldValues {
         my $object = $self->_GetSerializedByRef(delete $record->{Object});
 
         my $cf = $self->_GetSerializedByRef(delete $record->{CustomField});
+        next unless $cf && $cf->{Name}; # disabled CF on live object
         $record->{CustomField} = $cf->{Name};
 
         delete @$record{qw/id/};

commit b1dff1024adafe816f252df8b094505242742f78
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 21:26:04 2017 +0000

    For stability, sort records consistently

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 8a4415a4c..f44f6456e 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -51,6 +51,7 @@ package RT::Migrate::Serializer::JSON;
 use strict;
 use warnings;
 use JSON qw//;
+use List::MoreUtils 'uniq';
 
 use base 'RT::Migrate::Serializer';
 
@@ -231,18 +232,22 @@ sub _CanonicalizeManyToMany {
     my %args = (
         object_class => '',
         object_primary_ref => '',
+        object_sorter => '',
         primary_class => '',
         primary_key => 'ApplyTo',
         add_to_primary => undef,
+        sort_uniq => 0,
         canonicalize_object => sub { $_->{ObjectId} },
         @_,
     );
 
     my $object_class = $args{object_class};
     my $object_primary_ref = $args{object_primary_ref};
+    my $object_sorter = $args{object_sorter};
     my $primary_class = $args{primary_class};
     my $primary_key = $args{primary_key};
     my $add_to_primary = $args{add_to_primary};
+    my $sort_uniq = $args{sort_uniq};
     my $canonicalize_object = $args{canonicalize_object};
 
     if (my $objects = delete $self->{Records}{$object_class}) {
@@ -255,9 +260,16 @@ sub _CanonicalizeManyToMany {
             @{ $primary->{$primary_key} }
                 = grep defined,
                   map &$canonicalize_object,
-                  sort { $a->{SortOrder} <=> $b->{SortOrder} }
+                  sort { $a->{SortOrder} <=> $b->{SortOrder}
+                  || ($object_sorter ? $a->{$object_sorter} cmp $b->{$object_sorter} : 0) }
                   @{ $primary->{$primary_key} || [] };
 
+            if ($sort_uniq) {
+                @{ $primary->{$primary_key} }
+                    = uniq sort
+                      @{ $primary->{$primary_key} };
+            }
+
             if (ref($add_to_primary) eq 'CODE') {
                 $add_to_primary->($primary);
             }
@@ -397,6 +409,7 @@ sub CanonicalizeObjects {
         object_class        => 'RT::ObjectCustomField',
         object_primary_ref  => 'CustomField',
         primary_class       => 'RT::CustomField',
+        sort_uniq           => 1,
         canonicalize_object => sub {
             my $id = $_->{ObjectId};
             return $id if !ref($id);
@@ -408,6 +421,7 @@ sub CanonicalizeObjects {
     $self->_CanonicalizeManyToMany(
         object_class        => 'RT::CustomFieldValue',
         object_primary_ref  => 'CustomField',
+        object_sorter       => 'Name',
         primary_class       => 'RT::CustomField',
         primary_key         => 'Values',
         canonicalize_object => sub {
@@ -421,6 +435,7 @@ sub CanonicalizeObjects {
         object_class       => 'RT::ObjectClass',
         object_primary_ref => 'Class',
         primary_class      => 'RT::Class',
+        sort_uniq           => 1,
         canonicalize_object => sub {
             my $id = $_->{ObjectId};
             return $id if !ref($id);
@@ -433,6 +448,7 @@ sub CanonicalizeObjects {
         object_class       => 'RT::ObjectCustomRole',
         object_primary_ref => 'CustomRole',
         primary_class      => 'RT::CustomRole',
+        sort_uniq           => 1,
         canonicalize_object => sub {
             my $id = $_->{ObjectId};
             return $id if !ref($id);
@@ -514,15 +530,23 @@ sub WriteFile {
     $self->CanonicalizeCustomFields;
     $self->CanonicalizeArticles;
 
-    delete $self->{Records}{'RT::Attribute'};
+    my $all_records = $self->{Records};
 
-    for my $intype (keys %{ $self->{Records} }) {
+    delete $all_records->{'RT::Attribute'};
+
+    for my $intype (keys %$all_records) {
         my $outtype = $intype;
         $outtype =~ s/^RT:://;
         $outtype = $initialdataType{$outtype} || ($outtype . 's');
 
-        for my $id (keys %{ $self->{Records}{$intype} }) {
-            my $record = $self->{Records}{$intype}{$id};
+        my $records = $all_records->{$intype};
+
+        # sort by database id then serializer id for stability
+        for my $id (sort {
+            ($records->{$a}{id} || 0) <=> ($records->{$b}{id} || 0)
+            || $a cmp $b
+        } keys %$records) {
+            my $record = $records->{$id};
 
             next if $self->ShouldExcludeObject($intype, $id, $record);
 

commit 9b8a088c1ce323699ca031055effa1c04b5827ee
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 21:39:36 2017 +0000

    Clear user passwords and authtokens

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index f44f6456e..e8ab7e337 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -319,6 +319,9 @@ sub CanonicalizeUsers {
         delete $user->{Principal};
         delete $user->{PrincipalId};
 
+        delete $user->{Password};
+        delete $user->{AuthToken};
+
         my $object = RT::User->new(RT->SystemUser);
         $object->Load($user->{id});
 

commit 60590ae9c64c3be32f97188e2c7c5917678bffce
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 21:45:33 2017 +0000

    Remove more empty fields

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index e8ab7e337..4eeec006c 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -430,6 +430,8 @@ sub CanonicalizeObjects {
         canonicalize_object => sub {
             my %object = %$_;
             delete @object{qw/id CustomField/};
+            delete $object{Category} if !length($object{Category});
+            delete $object{Description} if !length($object{Description});
             return \%object;
         },
     );

commit 1f624c94f818196e8b6bb609b68b1c9a5be74a76
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 21:54:37 2017 +0000

    Handle disabled OCFVs by skipping them

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 4eeec006c..f967baef7 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -429,6 +429,8 @@ sub CanonicalizeObjects {
         primary_key         => 'Values',
         canonicalize_object => sub {
             my %object = %$_;
+            return if $object{Disabled} && !$self->{FollowDisabled};
+
             delete @object{qw/id CustomField/};
             delete $object{Category} if !length($object{Category});
             delete $object{Description} if !length($object{Description});

commit d9f8bf98f51492a562f2a3248952b995d9974cb6
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 21:57:11 2017 +0000

    Remove empty user keys

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index f967baef7..00ac3c94f 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -325,6 +325,11 @@ sub CanonicalizeUsers {
         my $object = RT::User->new(RT->SystemUser);
         $object->Load($user->{id});
 
+        for my $key (keys %$user) {
+            my $value = $user->{$key};
+            delete $user->{$key} if !defined($value) || !length($value);
+        }
+
         $user->{Privileged} = $object->Privileged ? JSON::true : JSON::false;
     }
 }

commit 759ad60dababe4a65c57e684315a2fdbc99b9003
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 23:05:41 2017 +0000

    Avoid undef warnings with sorting by sortorder

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 00ac3c94f..31e85823b 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -260,7 +260,7 @@ sub _CanonicalizeManyToMany {
             @{ $primary->{$primary_key} }
                 = grep defined,
                   map &$canonicalize_object,
-                  sort { $a->{SortOrder} <=> $b->{SortOrder}
+                  sort { ($a->{SortOrder}||0) <=> ($b->{SortOrder}||0)
                   || ($object_sorter ? $a->{$object_sorter} cmp $b->{$object_sorter} : 0) }
                   @{ $primary->{$primary_key} || [] };
 

commit f6ad69b91c556028eed688b09016375c39c11bf0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 23:32:16 2017 +0000

    Sort ObjectScrips by queue name for stability

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 31e85823b..6156e1a73 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -237,6 +237,7 @@ sub _CanonicalizeManyToMany {
         primary_key => 'ApplyTo',
         add_to_primary => undef,
         sort_uniq => 0,
+        finalize => undef,
         canonicalize_object => sub { $_->{ObjectId} },
         @_,
     );
@@ -248,6 +249,7 @@ sub _CanonicalizeManyToMany {
     my $primary_key = $args{primary_key};
     my $add_to_primary = $args{add_to_primary};
     my $sort_uniq = $args{sort_uniq};
+    my $finalize = $args{finalize};
     my $canonicalize_object = $args{canonicalize_object};
 
     if (my $objects = delete $self->{Records}{$object_class}) {
@@ -270,6 +272,10 @@ sub _CanonicalizeManyToMany {
                       @{ $primary->{$primary_key} };
             }
 
+            if ($finalize) {
+                $finalize->($primary);
+            }
+
             if (ref($add_to_primary) eq 'CODE') {
                 $add_to_primary->($primary);
             }
@@ -490,6 +496,10 @@ sub CanonicalizeObjects {
 
             return \%object;
         },
+        finalize => sub {
+            my $scrip = shift;
+            @{ $scrip->{Queue} } = sort { $a->{ObjectId} cmp $b->{ObjectId} } @{ $scrip->{Queue} };
+        },
     );
 }
 

commit 7048bee5b3203c154f5d8eea750e5f485335ee55
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 16:27:16 2017 +0000

    Don't serialized disabled OCFVs

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 6156e1a73..2ab8bf007 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -393,7 +393,14 @@ sub CanonicalizeCustomFields {
 sub CanonicalizeObjectCustomFieldValues {
     my $self = shift;
 
-    for my $record (values %{ $self->{Records}{'RT::ObjectCustomFieldValue'} }) {
+    for my $id (keys %{ $self->{Records}{'RT::ObjectCustomFieldValue'} }) {
+        my $record = $self->{Records}{'RT::ObjectCustomFieldValue'}{$id};
+
+        if ($record->{Disabled} && !$self->{FollowDisabled}) {
+            delete $self->{Records}{'RT::ObjectCustomFieldValue'}{$id};
+            next;
+        }
+
         my $object = $self->_GetSerializedByRef(delete $record->{Object});
 
         my $cf = $self->_GetSerializedByRef(delete $record->{CustomField});

commit cd50567a3b9fe24296a800f1b022567e06f22ffa
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 16:31:34 2017 +0000

    Factor out repeated deep hash lookups

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 2ab8bf007..66dd90237 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -252,13 +252,14 @@ sub _CanonicalizeManyToMany {
     my $finalize = $args{finalize};
     my $canonicalize_object = $args{canonicalize_object};
 
+    my $primary_records = $self->{Records}{$primary_class};
     if (my $objects = delete $self->{Records}{$object_class}) {
         for my $object (values %$objects) {
-            my $primary = $self->{Records}{$primary_class}{ ${ $object->{$object_primary_ref} } };
+            my $primary = $primary_records->{ ${ $object->{$object_primary_ref} } };
             push @{ $primary->{$primary_key} }, $object;
         }
 
-        for my $primary (values %{ $self->{Records}{$primary_class} }) {
+        for my $primary (values %$primary_records) {
             @{ $primary->{$primary_key} }
                 = grep defined,
                   map &$canonicalize_object,
@@ -343,14 +344,15 @@ sub CanonicalizeUsers {
 sub CanonicalizeGroups {
     my $self = shift;
 
-    for my $id (keys %{ $self->{Records}{'RT::Group'} }) {
-        my $group = $self->{Records}{'RT::Group'}{$id};
+    my $records = $self->{Records}{'RT::Group'};
+    for my $id (keys %$records) {
+        my $group = $records->{$id};
 
         # no need to serialize this because role groups are automatically
         # created; but we can't exclude this in ->Observe because then we
         # lose out on the group members
         if ($group->{Domain} =~ /-Role$/) {
-            delete $self->{Records}{'RT::Group'}{$id};
+            delete $records->{$id};
             next;
         }
 
@@ -393,11 +395,12 @@ sub CanonicalizeCustomFields {
 sub CanonicalizeObjectCustomFieldValues {
     my $self = shift;
 
-    for my $id (keys %{ $self->{Records}{'RT::ObjectCustomFieldValue'} }) {
-        my $record = $self->{Records}{'RT::ObjectCustomFieldValue'}{$id};
+    my $records = delete $self->{Records}{'RT::ObjectCustomFieldValue'};
+    for my $id (keys %$records) {
+        my $record = $records->{$id};
 
         if ($record->{Disabled} && !$self->{FollowDisabled}) {
-            delete $self->{Records}{'RT::ObjectCustomFieldValue'}{$id};
+            delete $records->{$id};
             next;
         }
 
@@ -411,8 +414,6 @@ sub CanonicalizeObjectCustomFieldValues {
 
         push @{ $object->{CustomFields} }, $record;
     }
-
-    delete $self->{Records}{'RT::ObjectCustomFieldValue'};
 }
 
 sub CanonicalizeArticles {

commit 99b8357c47fefa8f5a55ddfbc664724f5d4248a8
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 16:54:07 2017 +0000

    No need to include nobody member for single-member groups

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 66dd90237..919798232 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -367,17 +367,26 @@ sub CanonicalizeGroups {
 sub CanonicalizeGroupMembers {
     my $self = shift;
 
-    for my $record (values %{ $self->{Records}{'RT::GroupMember'} }) {
+    my $records = $self->{Records}{'RT::GroupMember'};
+    for my $id (keys %$records) {
+        my $record = $records->{$id};
         my $group = $self->_GetObjectByRef(delete $record->{GroupId})->Object;
+        my $member = $self->_GetObjectByRef(delete $record->{MemberId})->Object;
         my $domain = $group->Domain;
 
+        # no need to explicitly include a Nobody member
+        if ($member->isa('RT::User') && $member->Name eq 'Nobody' && $group->SingleMemberRoleGroup) {
+            delete $records->{$id};
+            next;
+        }
+
+
         $record->{Group} = $group->Name;
         $record->{GroupDomain} = $domain
             unless $domain eq 'UserDefined';
         $record->{GroupInstance} = \($group->InstanceObj->UID)
             if $domain =~ /-Role$/;
 
-        my $member = $self->_GetObjectByRef(delete $record->{MemberId})->Object;
         $record->{Class} = ref($member);
         $record->{Name} = $member->Name;
     }

commit a6910b6d0a0de130ba0026f04e77b64050e1fde4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 17:58:14 2017 +0000

    Add Sync option for JSON serializer

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 919798232..5245d1a66 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -79,6 +79,8 @@ sub Init {
 
     $self->{Records} = {};
 
+    $self->{Sync} = $args{Sync};
+
     $self->SUPER::Init(@_);
 }
 
@@ -419,7 +421,7 @@ sub CanonicalizeObjectCustomFieldValues {
         next unless $cf && $cf->{Name}; # disabled CF on live object
         $record->{CustomField} = $cf->{Name};
 
-        delete @$record{qw/id/};
+        delete $record->{id} unless $self->{Sync};
 
         push @{ $object->{CustomFields} }, $record;
     }
@@ -459,7 +461,8 @@ sub CanonicalizeObjects {
             my %object = %$_;
             return if $object{Disabled} && !$self->{FollowDisabled};
 
-            delete @object{qw/id CustomField/};
+            delete $object{CustomField};
+            delete $object{id} unless $self->{Sync};
             delete $object{Category} if !length($object{Category});
             delete $object{Description} if !length($object{Description});
             return \%object;
@@ -503,7 +506,8 @@ sub CanonicalizeObjects {
         },
         canonicalize_object => sub {
             my %object = %$_;
-            delete @object{qw/id Scrip/};
+            delete $object{Scrip};
+            delete $object{id} unless $self->{Sync};
 
             if (ref($_->{ObjectId})) {
                 my $serialized = $self->_GetSerializedByRef($_->{ObjectId});
@@ -570,6 +574,7 @@ sub WriteFile {
     $self->CanonicalizeArticles;
 
     my $all_records = $self->{Records};
+    my $sync = $self->{Sync};
 
     delete $all_records->{'RT::Attribute'};
 
@@ -594,7 +599,7 @@ sub WriteFile {
                     $record->{$key} = $self->CanonicalizeReference($record->{$key}, $record, $key);
                 }
             }
-            delete $record->{id};
+            delete $record->{id} unless $sync;
             delete $record->{Disabled} if !$record->{Disabled};
 
             push @{ $output{$outtype} }, $record;

commit 4acf32f761cbee9d92e4a69b24a664c3650d0695
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 18:05:11 2017 +0000

    Delete empty Values => [] from custom fields
    
    Especially important for CF types that don't use values

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 5245d1a66..8dc86664a 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -239,6 +239,7 @@ sub _CanonicalizeManyToMany {
         primary_key => 'ApplyTo',
         add_to_primary => undef,
         sort_uniq => 0,
+        delete_empty => 0,
         finalize => undef,
         canonicalize_object => sub { $_->{ObjectId} },
         @_,
@@ -251,6 +252,7 @@ sub _CanonicalizeManyToMany {
     my $primary_key = $args{primary_key};
     my $add_to_primary = $args{add_to_primary};
     my $sort_uniq = $args{sort_uniq};
+    my $delete_empty = $args{delete_empty};
     my $finalize = $args{finalize};
     my $canonicalize_object = $args{canonicalize_object};
 
@@ -275,6 +277,10 @@ sub _CanonicalizeManyToMany {
                       @{ $primary->{$primary_key} };
             }
 
+            if ($delete_empty) {
+                delete $primary->{$primary_key} if !@{ $primary->{$primary_key} };
+            }
+
             if ($finalize) {
                 $finalize->($primary);
             }
@@ -457,6 +463,7 @@ sub CanonicalizeObjects {
         object_sorter       => 'Name',
         primary_class       => 'RT::CustomField',
         primary_key         => 'Values',
+        delete_empty        => 1,
         canonicalize_object => sub {
             my %object = %$_;
             return if $object{Disabled} && !$self->{FollowDisabled};

commit db38a18d78642326d0e54e330559b565fb66eb51
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 22:07:38 2017 +0000

    Default FollowTickets and FollowTransactions to 0 for initialdata
    
    These don't round-trip anyway

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 8dc86664a..1ffa5ea93 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -59,8 +59,10 @@ sub Init {
     my $self = shift;
 
     my %args = (
-        Directory   => undef,
-        Force       => undef,
+        Directory           => undef,
+        Force               => undef,
+        FollowTickets       => 0,
+        FollowTransactions  => 0,
 
         @_,
     );
@@ -81,7 +83,7 @@ sub Init {
 
     $self->{Sync} = $args{Sync};
 
-    $self->SUPER::Init(@_);
+    $self->SUPER::Init(%args);
 }
 
 sub Export {

commit f2abed891fd204f757a1b75437ba0828cdcfab50
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 17:16:20 2017 +0000

    Default to exporting scrips and ACLs

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 1ffa5ea93..7e228ffec 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -63,6 +63,8 @@ sub Init {
         Force               => undef,
         FollowTickets       => 0,
         FollowTransactions  => 0,
+        FollowScrips        => 1,
+        FollowACL           => 1,
 
         @_,
     );

commit 8fbdadfb10348f0e9b5e852e04a00e1523421414
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 19:17:29 2017 +0000

    Initial rt-merge-initialdata

diff --git a/.gitignore b/.gitignore
index 1159ee89c..e6da7df18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@
 /sbin/rt-validator
 /sbin/rt-validate-aliases
 /sbin/rt-serializer
+/sbin/rt-merge-initialdata
 /sbin/rt-importer
 /sbin/rt-ldapimport
 /sbin/rt-passwd
diff --git a/Makefile.in b/Makefile.in
index cc418241f..f20edc946 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -149,6 +149,7 @@ SYSTEM_BINARIES		=	rt-attributes-viewer \
 				rt-importer \
 				rt-ldapimport \
 				rt-passwd \
+				rt-merge-initialdata \
 				rt-preferences-viewer \
 				rt-serializer \
 				rt-server \
diff --git a/configure.ac b/configure.ac
index d7685d80d..6eae1aac6 100755
--- a/configure.ac
+++ b/configure.ac
@@ -484,6 +484,7 @@ AC_CONFIG_FILES([
                  sbin/rt-setup-fulltext-index
                  sbin/rt-fulltext-indexer
                  sbin/rt-serializer
+                 sbin/rt-merge-initialdata
                  sbin/rt-importer
                  sbin/rt-passwd
                  bin/rt-crontool
diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
new file mode 100644
index 000000000..5550bad5f
--- /dev/null
+++ b/sbin/rt-merge-initialdata.in
@@ -0,0 +1,171 @@
+#!@PERL@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+
+# fix lib paths, some may be relative
+BEGIN {
+    require File::Spec;
+    my @libs = ("@RT_LIB_PATH@", "@LOCAL_LIB_PATH@");
+    my $bin_path;
+
+    for my $lib (@libs) {
+        unless ( File::Spec->file_name_is_absolute($lib) ) {
+            unless ($bin_path) {
+                if ( File::Spec->file_name_is_absolute(__FILE__) ) {
+                    $bin_path = ( File::Spec->splitpath(__FILE__) )[1];
+                }
+                else {
+                    require FindBin;
+                    no warnings "once";
+                    $bin_path = $FindBin::Bin;
+                }
+            }
+            $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
+        }
+        unshift @INC, $lib;
+    }
+
+}
+
+use RT;
+RT::LoadConfig();
+RT::Init();
+
+use JSON ();
+my $JSON = JSON->new->canonical;
+
+die "usage: $0 base edited\n" unless @ARGV == 2;
+my ($base_file, $edited_file) = @ARGV;
+
+my $base_records = slurp_json($base_file);
+my $edited_records = slurp_json($edited_file);
+
+my $export_options = delete $edited_records->{ExportOptions}
+    or die "Required metadata ExportOptions not present in $edited_file. Did you pass --sync to rt-serializer?";
+delete $base_records->{ExportOptions};
+
+my @record_types = qw/Groups Users Members ACL Queues Classes
+                      ScripActions ScripConditions Templates
+                      CustomFields CustomRoles Scrips
+                      Catalogs Assets Articles/;
+
+for my $type (@record_types) {
+    my ($new_records, $updated_records, $deleted_records) = find_differences(
+        $base_records->{$type},
+        $edited_records->{$type},
+        $type,
+    );
+
+    my $collection_class = "RT::$type";
+    my $record_class = $collection_class->RecordClass;
+
+    for my $new (@$new_records) {
+        my $record = $record_class->new(RT->SystemUser);
+    }
+
+    for my $deleted (@$deleted_records) {
+        my $record = $record_class->new(RT->SystemUser);
+        $record->Load($deleted->{id});
+    }
+
+    for (@$updated_records) {
+        my ($base, $edited) = @_;
+        my $record = $record_class->new(RT->SystemUser);
+        $record->Load($base->{id});
+    }
+}
+
+sub find_differences {
+    my $base_records = shift;
+    my $edited_records = shift;
+    my $type = shift;
+
+    my (@new, @deleted, @updated);
+    my (%base_by_id, %edited_by_id);
+
+    for my $base_record (@$base_records) {
+        my $id = $base_record->{id};
+
+        if (!$id) {
+            die "Missing id for this $type record in $base_file: " . encode_json($base_record);
+        }
+        $base_by_id{$id} = $base_record;
+    }
+
+    for my $edited_record (@$edited_records) {
+        my $id = $edited_record->{id};
+
+        if (!$id) {
+            push @new, $edited_record;
+        }
+        elsif (!$base_by_id{$id}) {
+            die "$type record in $edited_file has id ($id) that doesn't correspond with a record in $base_file: " . $JSON->encode($edited_record);
+        }
+        else {
+            my $base_record = delete $base_by_id{$id};
+            next if $JSON->encode($base_record) eq $JSON->encode($edited_record);
+            push @updated, [ $base_record => $edited_record ];
+        }
+    }
+
+    for my $base_record (values %base_by_id) {
+        push @deleted, $base_record;
+    }
+
+    return (\@new, \@updated, \@deleted);
+}
+
+sub slurp_json {
+    my $file = shift;
+    local $/;
+    open (my $f, '<', $file)
+        or die "Cannot open initialdata file '$file' for read: $@";
+    return $JSON->decode(scalar <$f>);
+}

commit 0348a357cc7226b911e19d4c71a0fdefd33a1ea4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 19:21:27 2017 +0000

    Implement record disabling/deleting

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 5550bad5f..b28902158 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -111,8 +111,27 @@ for my $type (@record_types) {
     }
 
     for my $deleted (@$deleted_records) {
+        my $id = $deleted->{id};
         my $record = $record_class->new(RT->SystemUser);
-        $record->Load($deleted->{id});
+        $record->Load($id);
+
+        my ($ok, $msg);
+        if ($record->can('SetDisabled') || $record->_Accessible('Disabled', 'write')) {
+            ($ok, $msg) = $record->SetDisabled(1);
+        }
+        elsif ($record->can('Delete')) {
+            ($ok, $msg) = $record->Delete;
+        }
+        else {
+            die "No method to delete $record_class #$id";
+        }
+
+        if ($ok) {
+            RT->Logger->debug("Deleted $record_class $id: $msg");
+        }
+        else {
+            RT->Logger->error("Unable to delete $record_class $id: $msg");
+        }
     }
 
     for (@$updated_records) {

commit 04e90301f5ea6807cd302da8164558c36afa558c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 21:19:33 2017 +0000

    Handle simple scalar column updates

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index b28902158..6c7894e35 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -46,6 +46,7 @@
 # those contributions and any derivatives thereof.
 #
 # END BPS TAGGED BLOCK }}}
+use 5.010;
 use strict;
 use warnings;
 
@@ -78,6 +79,7 @@ use RT;
 RT::LoadConfig();
 RT::Init();
 
+use List::MoreUtils 'uniq';
 use JSON ();
 my $JSON = JSON->new->canonical;
 
@@ -96,6 +98,10 @@ my @record_types = qw/Groups Users Members ACL Queues Classes
                       CustomFields CustomRoles Scrips
                       Catalogs Assets Articles/;
 
+my %class_type = (
+    Members => 'GroupMembers',
+);
+
 for my $type (@record_types) {
     my ($new_records, $updated_records, $deleted_records) = find_differences(
         $base_records->{$type},
@@ -103,9 +109,50 @@ for my $type (@record_types) {
         $type,
     );
 
-    my $collection_class = "RT::$type";
+    my $collection_class = "RT::" . ($class_type{$type} || $type);
     my $record_class = $collection_class->RecordClass;
 
+    for (@$updated_records) {
+        my ($base, $edited) = @$_;
+        my $id = $base->{id};
+        my $record = $record_class->new(RT->SystemUser);
+        $record->Load($id);
+        if (!$record->Id) {
+            RT->Logger->error("Unable to load $record_class $id for updating; skipping");
+            next;
+        }
+
+        for my $column (uniq sort keys(%$base), keys(%$edited)) {
+            my $base_value = $base->{$column} // "";
+            my $edited_value = $edited->{$column} // "";
+
+            for ($base_value, $edited_value) {
+                $_ = !!$_ if JSON::is_bool($_)
+            }
+
+            if (!ref($base_value) && !ref($edited_value)) {
+                next if $base_value eq $edited_value;
+
+                my $method = "Set$column";
+                if ($record->can($method) || $record->_Accessible($column, 'write')) {
+                    my ($ok, $msg) = $record->$method($edited_value);
+                    if ($ok) {
+                        RT->Logger->debug("Updated $record_class #$id $column: $msg");
+                    }
+                    else {
+                        RT->Logger->error("Unable to update $record_class #$id $column: $msg");
+                    }
+                }
+                else {
+                    die "Unable to handle updating $record_class $column (no method)";
+                }
+            }
+            else {
+                die "Unable to handle updating $record_class $column (composite value)";
+            }
+        }
+    }
+
     for my $new (@$new_records) {
         my $record = $record_class->new(RT->SystemUser);
     }
@@ -127,18 +174,12 @@ for my $type (@record_types) {
         }
 
         if ($ok) {
-            RT->Logger->debug("Deleted $record_class $id: $msg");
+            RT->Logger->debug("Deleted $record_class #$id: $msg");
         }
         else {
-            RT->Logger->error("Unable to delete $record_class $id: $msg");
+            RT->Logger->error("Unable to delete $record_class #$id: $msg");
         }
     }
-
-    for (@$updated_records) {
-        my ($base, $edited) = @_;
-        my $record = $record_class->new(RT->SystemUser);
-        $record->Load($base->{id});
-    }
 }
 
 sub find_differences {

commit c642683f448e6f37c202e0e4c3e9f316164b07f4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 21:21:00 2017 +0000

    Factor out an is_deeply_equal and use it for updates instead of eq

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 6c7894e35..d78ca2602 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -130,9 +130,9 @@ for my $type (@record_types) {
                 $_ = !!$_ if JSON::is_bool($_)
             }
 
-            if (!ref($base_value) && !ref($edited_value)) {
-                next if $base_value eq $edited_value;
+            next if is_deeply_equal($base_value, $edited_value);
 
+            if (!ref($base_value) && !ref($edited_value)) {
                 my $method = "Set$column";
                 if ($record->can($method) || $record->_Accessible($column, 'write')) {
                     my ($ok, $msg) = $record->$method($edited_value);
@@ -210,7 +210,7 @@ sub find_differences {
         }
         else {
             my $base_record = delete $base_by_id{$id};
-            next if $JSON->encode($base_record) eq $JSON->encode($edited_record);
+            next if is_deeply_equal($base_record, $edited_record);
             push @updated, [ $base_record => $edited_record ];
         }
     }
@@ -222,6 +222,11 @@ sub find_differences {
     return (\@new, \@updated, \@deleted);
 }
 
+sub is_deeply_equal {
+    my ($left, $right) = @_;
+    return $JSON->encode($left) eq $JSON->encode($right);
+}
+
 sub slurp_json {
     my $file = shift;
     local $/;

commit 0d6b6c7dba954114b38e57462833aea23e7d3b6b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 21:38:42 2017 +0000

    Avoid blowing up on comparing scalars

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index d78ca2602..203677a6a 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -224,7 +224,8 @@ sub find_differences {
 
 sub is_deeply_equal {
     my ($left, $right) = @_;
-    return $JSON->encode($left) eq $JSON->encode($right);
+    # use [] to avoid nonref issues without changing $JSON itself
+    return $JSON->encode([$left]) eq $JSON->encode([$right]);
 }
 
 sub slurp_json {

commit cbe2e787c6b487e0b1dc63bc9ab5a4cb068ac490
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 21:38:59 2017 +0000

    Avoid spurious warnings when base!=edited but edited=current

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 203677a6a..348d6983b 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -135,6 +135,9 @@ for my $type (@record_types) {
             if (!ref($base_value) && !ref($edited_value)) {
                 my $method = "Set$column";
                 if ($record->can($method) || $record->_Accessible($column, 'write')) {
+                    # skip if it was already updated outside initialdata
+                    next if ($record->$method//"") eq $edited_value;
+
                     my ($ok, $msg) = $record->$method($edited_value);
                     if ($ok) {
                         RT->Logger->debug("Updated $record_class #$id $column: $msg");

commit 5d76602f5abff480177712a18a8148793212d56f
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 21:39:17 2017 +0000

    Reuse InsertData
    
    This is bananas. It relies on a quirk of Perl's "open" function
    which treats the filename as its content if you pass it as a reference.
    In other words:
    
        my $json = JSON->encode({ ... });
        open my $handle, '<', \$json;
    
    Will have $handle vend the $json content.
    
    This lets us reuse RT::Handle::InsertData without a large refactor

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 348d6983b..b88c5b323 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -102,6 +102,8 @@ my %class_type = (
     Members => 'GroupMembers',
 );
 
+my %new_for_insertdata;
+
 for my $type (@record_types) {
     my ($new_records, $updated_records, $deleted_records) = find_differences(
         $base_records->{$type},
@@ -112,6 +114,8 @@ for my $type (@record_types) {
     my $collection_class = "RT::" . ($class_type{$type} || $type);
     my $record_class = $collection_class->RecordClass;
 
+    $new_for_insertdata{$type} = $new_records;
+
     for (@$updated_records) {
         my ($base, $edited) = @$_;
         my $id = $base->{id};
@@ -156,10 +160,6 @@ for my $type (@record_types) {
         }
     }
 
-    for my $new (@$new_records) {
-        my $record = $record_class->new(RT->SystemUser);
-    }
-
     for my $deleted (@$deleted_records) {
         my $id = $deleted->{id};
         my $record = $record_class->new(RT->SystemUser);
@@ -185,6 +185,9 @@ for my $type (@record_types) {
     }
 }
 
+my $new_json = $JSON->encode(\%new_for_insertdata);
+$RT::Handle->InsertData(\$new_json, undef, disconnect_after => 0);
+
 sub find_differences {
     my $base_records = shift;
     my $edited_records = shift;

commit 6f1a539d4733828cdd7640e8832190023a56cc87
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Thu Mar 23 22:05:46 2017 +0000

    Prompt user for conflict resolution

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index b88c5b323..38f81f790 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -139,10 +139,35 @@ for my $type (@record_types) {
             if (!ref($base_value) && !ref($edited_value)) {
                 my $method = "Set$column";
                 if ($record->can($method) || $record->_Accessible($column, 'write')) {
+                    my $current_value = $record->$column // "";
+
                     # skip if it was already updated outside initialdata
-                    next if ($record->$method//"") eq $edited_value;
+                    next if $current_value eq $edited_value;
+
+                    my $new_value = $edited_value;
+
+                    if ($base_value ne $current_value) {
+                        my $decision = decide_merge(
+                            base_record    => $base,
+                            edited_record  => $edited,
+                            current_record => $record,
+                            column         => $column,
+                            base_value     => $base_value,
+                            edited_value   => $edited_value,
+                            current_value  => $current_value,
+                        );
+                        if ($decision eq 'base') {
+                            $new_value = $base_value;
+                        }
+                        elsif ($decision eq 'edited') {
+                            # continue
+                        }
+                        elsif ($decision eq 'current') {
+                            next;
+                        }
+                    }
 
-                    my ($ok, $msg) = $record->$method($edited_value);
+                    my ($ok, $msg) = $record->$method($new_value);
                     if ($ok) {
                         RT->Logger->debug("Updated $record_class #$id $column: $msg");
                     }
@@ -241,3 +266,53 @@ sub slurp_json {
         or die "Cannot open initialdata file '$file' for read: $@";
     return $JSON->decode(scalar <$f>);
 }
+
+sub decide_merge {
+    my %args = (
+        base_record    => undef,
+        edited_record  => undef,
+        current_record => undef,
+        column         => undef,
+        base_value     => undef,
+        edited_value   => undef,
+        current_value  => undef,
+        @_,
+    );
+
+    my $record = $args{current_record};
+    my $id = $record->id;
+    my $type = ref($record);
+    $type =~ s/^RT:://;
+
+    local $| = 1;
+    while (1) {
+        print "\n";
+        print "Conflict resolution required for $type #$id $args{column}:\n";
+        print "    Base value:     $args{base_value}\n";
+        print "    Edited value:   $args{edited_value}\n";
+        print "    Database value: $args{current_value}\n";
+        print "Would you like to (q)uit, (s)ee more context, or set value to (b)ase, (e)dited, or (d)atabase? ";
+
+        my $answer = (scalar <STDIN>) // "";
+        print "\n";
+
+        exit 1 if $answer =~ /^q/i;
+        return "base" if $answer =~ /^b/i;
+        return "edited" if $answer =~ /^e/i;
+        return "current" if $answer =~ /^d/i;
+
+        if ($answer =~ /^s/i) {
+            my %values = %{ $record->{values} };
+            delete $values{$_} for grep { ($values{$_}//"") eq ""} keys %values;
+            for ('base', 'edited', ['current', \%values]) {
+                my ($key, $record) = ref($_)
+                                   ? @$_
+                                   : ($_, $args{$_ . '_record'});
+                print "\u$key:\n";
+                print "    $_\n"
+                    for split /\n/, JSON->new->pretty->canonical->encode($record);
+            }
+        }
+    }
+}
+

commit 01de263883dd5f7369b9b8167a81372976360437
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 17:46:47 2017 +0000

    Add CLI opt parser and docs for rt-merge-initialdata

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 38f81f790..1a2ebeda3 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -79,12 +79,27 @@ use RT;
 RT::LoadConfig();
 RT::Init();
 
+use Getopt::Long;
+use Pod::Usage qw//;
 use List::MoreUtils 'uniq';
 use JSON ();
 my $JSON = JSON->new->canonical;
 
-die "usage: $0 base edited\n" unless @ARGV == 2;
-my ($base_file, $edited_file) = @ARGV;
+my %OPT;
+GetOptions(
+    \%OPT,
+    "help|h",
+
+    "base=s",
+    "edited=s",
+);
+
+exit Pod::Usage::pod2usage(-verbose => 1) if $OPT{help};
+
+my $base_file = $OPT{base};
+my $edited_file = $OPT{edited};
+
+exit Pod::Usage::pod2usage unless $base_file && $edited_file;
 
 my $base_records = slurp_json($base_file);
 my $edited_records = slurp_json($edited_file);
@@ -316,3 +331,51 @@ sub decide_merge {
     }
 }
 
+=head1 NAME
+
+rt-merge-initialdata - Merge changes from an edited initialdata
+
+=head1 SYNOPSIS
+
+    rt-merge-initialdata --base initialdata.json --edited updated.json
+
+
+This tool allows you to edit a JSON-based initialdata file and merge
+those changes into your RT instance. The intended workflow is you first
+create an initialdata from your running RT config with:
+
+    sbin/rt-serializer --sync --format JSON
+
+Then, copy the resulting C<initialdata.json> into a new file. Edit that
+C<updated.json> to create, update, and delete records (see below for
+considerations).
+
+Then run F<rt-merge-initialdata> like so:
+
+    rt-merge-initialdata --base initialdata.json --edited updated.json
+
+F<rt-merge-initialdata> will update any records that you edited, create
+any records you added, and disable (or delete) any records you removed.
+Note that the C<id> field is used to track identity across the base,
+edited, and database versions of the same record, so take care when
+dealing with it. (Any record without an C<id> is treated as a new one to
+be created, and any record in the base which does not have a
+corresponding C<id> in the edited file will be disabled/deleted).
+
+=head2 OPTIONS
+
+=over
+
+=item B<--base> I<filename>
+
+The filename of the "base" initialdata. This should be a pristine initialdata
+file exported by F<sbin/rt-serializer>.
+
+=item B<--edited> I<filename>
+
+The filename of the "edited" initialdata. This should be a copy of the
+filename provided to C<--base> with your edits made to it.
+
+=back
+
+=cut

commit d2fade7ae4bb481f4de10349dc16070d31acc2de
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 17:47:34 2017 +0000

    Add --merge-strategy flag

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 1a2ebeda3..0d83ff0be 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -92,6 +92,7 @@ GetOptions(
 
     "base=s",
     "edited=s",
+    "merge-strategy=s",
 );
 
 exit Pod::Usage::pod2usage(-verbose => 1) if $OPT{help};
@@ -101,6 +102,13 @@ my $edited_file = $OPT{edited};
 
 exit Pod::Usage::pod2usage unless $base_file && $edited_file;
 
+my $merge_strategy = $OPT{'merge-strategy'};
+
+die "Invalid value for --merge-strategy. Expected 'base', 'current', 'edited', or 'quit'."
+    unless !$merge_strategy || $merge_strategy =~ /^(base|current|edited|quit)$/;
+
+RT->Config->Set( LogToSTDERR => $OPT{log} ) if $OPT{log};
+
 my $base_records = slurp_json($base_file);
 my $edited_records = slurp_json($edited_file);
 
@@ -306,6 +314,13 @@ sub decide_merge {
         print "    Base value:     $args{base_value}\n";
         print "    Edited value:   $args{edited_value}\n";
         print "    Database value: $args{current_value}\n";
+
+        if ($merge_strategy) {
+            print "Using specified merge strategy '$merge_strategy'.\n" unless $OPT{quiet};
+            exit if $merge_strategy eq 'quit';
+            return $merge_strategy;
+        }
+
         print "Would you like to (q)uit, (s)ee more context, or set value to (b)ase, (e)dited, or (d)atabase? ";
 
         my $answer = (scalar <STDIN>) // "";
@@ -362,6 +377,13 @@ dealing with it. (Any record without an C<id> is treated as a new one to
 be created, and any record in the base which does not have a
 corresponding C<id> in the edited file will be disabled/deleted).
 
+Any changes made in the database after the base was exported will be
+untouched by F<rt-merge-initialdata>. If it turns out that a field was
+changed in both the edited initialdata I<and> the database, then a
+conflict will be presented to you with a prompt asking which version to
+keep. The C<--merge-strategy> flag lets you decide in advance to handle
+all such conflicts using a consistent strategy, to avoid prompting.
+
 =head2 OPTIONS
 
 =over
@@ -376,6 +398,35 @@ file exported by F<sbin/rt-serializer>.
 The filename of the "edited" initialdata. This should be a copy of the
 filename provided to C<--base> with your edits made to it.
 
+=item B<--merge-strategy> I<strategy>
+
+Automatically resolve all conflicts without prompting the user. The
+following merge strategies are available:
+
+=over 4
+
+=item C<edited>
+
+Take the edits as specified in the edited initialdata, overwriting the
+change made in the database after the base was exported.
+
+=item C<current>
+
+Keep the database's current value, ignoring the change made in the
+edited initialdata.
+
+=item C<base>
+
+Revert to the base initialdata's value, rolling back the change made in
+the database and ignoring the change made in the edited initialdata.
+
+=item C<quit>
+
+Abort processing at the first conflict. Note that this will likely
+result in a subset of the edits being applied to your database.
+
+=back
+
 =back
 
 =cut

commit e748fd600221a23cc092fa0bbbee9577865c5ac4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 17:47:50 2017 +0000

    Add --quiet and --log options

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 0d83ff0be..04fb1900e 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -89,6 +89,8 @@ my %OPT;
 GetOptions(
     \%OPT,
     "help|h",
+    "quiet|q!",
+    "log=s",
 
     "base=s",
     "edited=s",
@@ -133,6 +135,7 @@ for my $type (@record_types) {
         $edited_records->{$type},
         $type,
     );
+    RT->Logger->info("Merging changes from $type: " . scalar(@$new_records) . " new records, " . scalar(@$updated_records) . " updated, " . scalar(@$deleted_records) . " deleted");
 
     my $collection_class = "RT::" . ($class_type{$type} || $type);
     my $record_class = $collection_class->RecordClass;
@@ -309,11 +312,13 @@ sub decide_merge {
 
     local $| = 1;
     while (1) {
-        print "\n";
-        print "Conflict resolution required for $type #$id $args{column}:\n";
-        print "    Base value:     $args{base_value}\n";
-        print "    Edited value:   $args{edited_value}\n";
-        print "    Database value: $args{current_value}\n";
+        unless ($OPT{quiet} && $merge_strategy) {
+            print "\n";
+            print "Conflict resolution required for $type #$id $args{column}:\n";
+            print "    Base value:     $args{base_value}\n";
+            print "    Edited value:   $args{edited_value}\n";
+            print "    Database value: $args{current_value}\n";
+        }
 
         if ($merge_strategy) {
             print "Using specified merge strategy '$merge_strategy'.\n" unless $OPT{quiet};
@@ -427,6 +432,14 @@ result in a subset of the edits being applied to your database.
 
 =back
 
+=item B<--quiet>
+
+Avoid nonessential output.
+
+=item B<--log> I<level>
+
+Adjust LogToSTDERR config option.
+
 =back
 
 =cut

commit fdc8bb32e8cca3e09cdbbd502ef42a070c5c152d
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 17:58:12 2017 +0000

    Add --dryrun flag

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 04fb1900e..f76c9ec7a 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -77,7 +77,6 @@ BEGIN {
 
 use RT;
 RT::LoadConfig();
-RT::Init();
 
 use Getopt::Long;
 use Pod::Usage qw//;
@@ -91,6 +90,7 @@ GetOptions(
     "help|h",
     "quiet|q!",
     "log=s",
+    "dryrun",
 
     "base=s",
     "edited=s",
@@ -111,6 +111,11 @@ die "Invalid value for --merge-strategy. Expected 'base', 'current', 'edited', o
 
 RT->Config->Set( LogToSTDERR => $OPT{log} ) if $OPT{log};
 
+RT::Init();
+
+my $dryrun = $OPT{'dryrun'};
+RT->Logger->debug("dryrun enabled") if $dryrun;
+
 my $base_records = slurp_json($base_file);
 my $edited_records = slurp_json($edited_file);
 
@@ -140,7 +145,12 @@ for my $type (@record_types) {
     my $collection_class = "RT::" . ($class_type{$type} || $type);
     my $record_class = $collection_class->RecordClass;
 
-    $new_for_insertdata{$type} = $new_records;
+    if ($dryrun) {
+        RT->Logger->debug("Skipping create of " . scalar(@$new_records) . "x $type because of dry run.") if @$new_records;
+    }
+    else {
+        $new_for_insertdata{$type} = $new_records;
+    }
 
     for (@$updated_records) {
         my ($base, $edited) = @$_;
@@ -193,6 +203,11 @@ for my $type (@record_types) {
                         }
                     }
 
+                    if ($dryrun) {
+                        RT->Logger->debug("Skipping update $record_class #$id $column from '$current_value' to '$new_value' because of dry run.");
+                        next;
+                    }
+
                     my ($ok, $msg) = $record->$method($new_value);
                     if ($ok) {
                         RT->Logger->debug("Updated $record_class #$id $column: $msg");
@@ -218,9 +233,17 @@ for my $type (@record_types) {
 
         my ($ok, $msg);
         if ($record->can('SetDisabled') || $record->_Accessible('Disabled', 'write')) {
+            if ($dryrun) {
+                RT->Logger->debug("Skipping disabling of $record_class #$id because of dry run.");
+                next;
+            }
             ($ok, $msg) = $record->SetDisabled(1);
         }
         elsif ($record->can('Delete')) {
+            if ($dryrun) {
+                RT->Logger->debug("Skipping delete of $record_class #$id because of dry run.");
+                next;
+            }
             ($ok, $msg) = $record->Delete;
         }
         else {
@@ -236,8 +259,10 @@ for my $type (@record_types) {
     }
 }
 
-my $new_json = $JSON->encode(\%new_for_insertdata);
-$RT::Handle->InsertData(\$new_json, undef, disconnect_after => 0);
+if (grep { scalar(@$_) } values %new_for_insertdata) {
+    my $new_json = $JSON->encode(\%new_for_insertdata);
+    $RT::Handle->InsertData(\$new_json, undef, disconnect_after => 0);
+}
 
 sub find_differences {
     my $base_records = shift;
@@ -440,6 +465,12 @@ Avoid nonessential output.
 
 Adjust LogToSTDERR config option.
 
+=item B<--dryrun>
+
+Attempt to process changes but skip making any actual changes to the
+database. Changes that would have been made are logged at level C<debug>
+so you may wish to pass C<--log=debug> to see them.
+
 =back
 
 =cut

commit a3016f3d79fa0a0d845a65e1e6db0347bdcc526f
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 20:43:07 2017 +0000

    Avoid spurious 0 0 0 changes log entries

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index f76c9ec7a..6f03b84dc 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -140,6 +140,11 @@ for my $type (@record_types) {
         $edited_records->{$type},
         $type,
     );
+
+    next if !@$new_records
+         && !@$updated_records
+         && !@$deleted_records;
+
     RT->Logger->info("Merging changes from $type: " . scalar(@$new_records) . " new records, " . scalar(@$updated_records) . " updated, " . scalar(@$deleted_records) . " deleted");
 
     my $collection_class = "RT::" . ($class_type{$type} || $type);

commit 0e930b614c6e11641aad034cd76efeabaceab9a8
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 20:43:40 2017 +0000

    Factor out updating records (or not due to dryrun) and reporting results

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 6f03b84dc..ab43a178a 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -132,6 +132,23 @@ my %class_type = (
     Members => 'GroupMembers',
 );
 
+sub update_record {
+    my ($record, $description, $cb) = @_;
+
+    if ($dryrun) {
+        RT->Logger->debug("Skipping $description because of dry run.");
+        return;
+    }
+
+    my ($ok, $msg) = $cb->();
+    if ($ok) {
+        RT->Logger->debug("\u$description: $msg");
+    }
+    else {
+        RT->Logger->error("Unable to $description: $msg");
+    }
+}
+
 my %new_for_insertdata;
 
 for my $type (@record_types) {
@@ -208,18 +225,9 @@ for my $type (@record_types) {
                         }
                     }
 
-                    if ($dryrun) {
-                        RT->Logger->debug("Skipping update $record_class #$id $column from '$current_value' to '$new_value' because of dry run.");
-                        next;
-                    }
-
-                    my ($ok, $msg) = $record->$method($new_value);
-                    if ($ok) {
-                        RT->Logger->debug("Updated $record_class #$id $column: $msg");
-                    }
-                    else {
-                        RT->Logger->error("Unable to update $record_class #$id $column: $msg");
-                    }
+                    update_record $record, "update $record_class #$id $column from '$current_value' to '$new_value'" => sub {
+                        $record->$method($new_value);
+                    };
                 }
                 else {
                     die "Unable to handle updating $record_class $column (no method)";
@@ -236,31 +244,19 @@ for my $type (@record_types) {
         my $record = $record_class->new(RT->SystemUser);
         $record->Load($id);
 
-        my ($ok, $msg);
         if ($record->can('SetDisabled') || $record->_Accessible('Disabled', 'write')) {
-            if ($dryrun) {
-                RT->Logger->debug("Skipping disabling of $record_class #$id because of dry run.");
-                next;
-            }
-            ($ok, $msg) = $record->SetDisabled(1);
+            update_record $record, "disable $record_class #$id" => sub {
+                $record->SetDisabled(1);
+            };
         }
         elsif ($record->can('Delete')) {
-            if ($dryrun) {
-                RT->Logger->debug("Skipping delete of $record_class #$id because of dry run.");
-                next;
-            }
-            ($ok, $msg) = $record->Delete;
+            update_record $record, "delete $record_class #$id" => sub {
+                $record->Delete;
+            };
         }
         else {
             die "No method to delete $record_class #$id";
         }
-
-        if ($ok) {
-            RT->Logger->debug("Deleted $record_class #$id: $msg");
-        }
-        else {
-            RT->Logger->error("Unable to delete $record_class #$id: $msg");
-        }
     }
 }
 

commit 8c9141541148a896b59c06e572836d8eff740f57
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 20:45:16 2017 +0000

    Merge changes to CustomField.ApplyTo

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index ab43a178a..33f9c2f17 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -149,6 +149,90 @@ sub update_record {
     }
 }
 
+my %special_merger = (
+    CustomFields => {
+        ApplyTo => sub {
+            my %args = @_;
+            my $cf = $args{current_record};
+            my $name = $cf->Name;
+
+            my @base_queues = ref $args{base_value}
+                            ? @{ $args{base_value} }
+                            : $args{base_value};
+            my @edited_queues = ref $args{edited_value}
+                              ? @{ $args{edited_value} }
+                              : $args{edited_value};
+
+            # canonicalize to IDs
+            for (@base_queues, @edited_queues) {
+                next if $_ eq '0';
+
+                my $queue = RT::Queue->new(RT->SystemUser);
+                my ($ok, $msg) = $queue->Load($_);
+                die "Unable to load queue $_: $msg" if !$ok;
+                $_ = $queue->Id;
+            }
+
+            @base_queues = sort { $a <=> $b } @base_queues;
+            @edited_queues = sort { $a <=> $b } @edited_queues;
+
+            my (@add, @remove);
+
+            while (@base_queues || @edited_queues) {
+                my $base_queue = $base_queues[0];
+                my $edited_queue = $edited_queues[0];
+
+                # same queue, continue with no change
+                if (($base_queue//-1) == ($edited_queue//-1)) {
+                    shift @base_queues;
+                    shift @edited_queues;
+                }
+                # ran out of base queues, add edited queue
+                elsif (!defined($base_queue)) {
+                    shift @edited_queues;
+                    push @add, $edited_queue;
+                }
+                # ran out of edited queues, remove base queue
+                elsif (!defined($edited_queue)) {
+                    shift @base_queues;
+                    push @remove, $base_queue;
+                }
+                # there's a base queue not in edited, so remove
+                elsif ($base_queue < $edited_queue) {
+                    shift @base_queues;
+                    push @remove, $base_queue;
+                }
+                # there's a edited queue not in base, so add
+                elsif ($edited_queue < $base_queue) {
+                    shift @edited_queues;
+                    push @add, $edited_queue;
+                }
+                else {
+                    die "Should never happen";
+                }
+            }
+
+            for my $id (@add) {
+                my $queue = RT::Queue->new(RT->SystemUser);
+                $queue->Load($id) if $id;
+                my $queue_name = $id ? "to queue " . $queue->Name : "globally";
+                update_record $cf, "add custom field $name $queue_name" => sub {
+                    $cf->AddToObject($queue);
+                };
+            }
+
+            for my $id (@remove) {
+                my $queue = RT::Queue->new(RT->SystemUser);
+                $queue->Load($id) if $id;
+                my $queue_name = $id ? "from queue " . $queue->Name : "globally";
+                update_record $cf, "remove custom field $name $queue_name" => sub {
+                    $cf->RemoveFromObject($queue);
+                };
+            }
+        }
+    },
+);
+
 my %new_for_insertdata;
 
 for my $type (@record_types) {
@@ -194,7 +278,17 @@ for my $type (@record_types) {
 
             next if is_deeply_equal($base_value, $edited_value);
 
-            if (!ref($base_value) && !ref($edited_value)) {
+            if (my $merger = $special_merger{$type}{$column}) {
+                $merger->(
+                    base_record    => $base,
+                    edited_record  => $edited,
+                    current_record => $record,
+                    column         => $column,
+                    base_value     => $base_value,
+                    edited_value   => $edited_value,
+                );
+            }
+            elsif (!ref($base_value) && !ref($edited_value)) {
                 my $method = "Set$column";
                 if ($record->can($method) || $record->_Accessible($column, 'write')) {
                     my $current_value = $record->$column // "";

commit 97ea644373748348dada39deda9a91cfb1f571b1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 20:56:10 2017 +0000

    Refactor CustomField ApplyTo merger to reuse for Class ApplyTo

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index 33f9c2f17..d56af0243 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -149,87 +149,104 @@ sub update_record {
     }
 }
 
+sub _object_merger {
+   my %args = @_;
+   my $primary = $args{current_record};
+   my $primary_class = ref($primary);
+   my $secondary_class = $args{secondary_class};
+   my $name = $primary->Name;
+
+   my @base_objects = ref $args{base_value}
+                    ? @{ $args{base_value} }
+                    : $args{base_value};
+   my @edited_objects = ref $args{edited_value}
+                      ? @{ $args{edited_value} }
+                      : $args{edited_value};
+
+   # canonicalize to IDs
+   for (@base_objects, @edited_objects) {
+       next if $_ eq '0';
+
+       my $secondary = $secondary_class->new(RT->SystemUser);
+       my ($ok, $msg) = $secondary->Load($_);
+       die "Unable to load $secondary_class $_: $msg" if !$ok;
+       $_ = $secondary->Id;
+   }
+
+   @base_objects = sort { $a <=> $b } @base_objects;
+   @edited_objects = sort { $a <=> $b } @edited_objects;
+
+   my (@add, @remove);
+
+   while (@base_objects || @edited_objects) {
+       my $base_object = $base_objects[0];
+       my $edited_object = $edited_objects[0];
+
+       # same queue, continue with no change
+       if (($base_object//-1) == ($edited_object//-1)) {
+           shift @base_objects;
+           shift @edited_objects;
+       }
+       # ran out of base queues, add edited queue
+       elsif (!defined($base_object)) {
+           shift @edited_objects;
+           push @add, $edited_object;
+       }
+       # ran out of edited queues, remove base queue
+       elsif (!defined($edited_object)) {
+           shift @base_objects;
+           push @remove, $base_object;
+       }
+       # there's a base queue not in edited, so remove
+       elsif ($base_object < $edited_object) {
+           shift @base_objects;
+           push @remove, $base_object;
+       }
+       # there's a edited queue not in base, so add
+       elsif ($edited_object < $base_object) {
+           shift @edited_objects;
+           push @add, $edited_object;
+       }
+       else {
+           die "Should never happen";
+       }
+   }
+
+   for my $id (@add) {
+       my $secondary = $secondary_class->new(RT->SystemUser);
+       $secondary->Load($id) if $id;
+       my $secondary_name = $id ? "to $secondary_class " . $secondary->Name : "globally";
+       update_record $primary, "add $primary_class $name $secondary_name" => sub {
+           $primary->AddToObject($secondary);
+       };
+   }
+
+   for my $id (@remove) {
+       my $secondary = $secondary_class->new(RT->SystemUser);
+       $secondary->Load($id) if $id;
+       my $secondary_name = $id ? "from $secondary_class " . $secondary->Name : "globally";
+       update_record $primary, "remove $primary_class $name $secondary_name" => sub {
+           $primary->RemoveFromObject($secondary);
+       };
+   }
+}
+
 my %special_merger = (
     CustomFields => {
         ApplyTo => sub {
-            my %args = @_;
-            my $cf = $args{current_record};
-            my $name = $cf->Name;
-
-            my @base_queues = ref $args{base_value}
-                            ? @{ $args{base_value} }
-                            : $args{base_value};
-            my @edited_queues = ref $args{edited_value}
-                              ? @{ $args{edited_value} }
-                              : $args{edited_value};
-
-            # canonicalize to IDs
-            for (@base_queues, @edited_queues) {
-                next if $_ eq '0';
-
-                my $queue = RT::Queue->new(RT->SystemUser);
-                my ($ok, $msg) = $queue->Load($_);
-                die "Unable to load queue $_: $msg" if !$ok;
-                $_ = $queue->Id;
-            }
-
-            @base_queues = sort { $a <=> $b } @base_queues;
-            @edited_queues = sort { $a <=> $b } @edited_queues;
-
-            my (@add, @remove);
-
-            while (@base_queues || @edited_queues) {
-                my $base_queue = $base_queues[0];
-                my $edited_queue = $edited_queues[0];
-
-                # same queue, continue with no change
-                if (($base_queue//-1) == ($edited_queue//-1)) {
-                    shift @base_queues;
-                    shift @edited_queues;
-                }
-                # ran out of base queues, add edited queue
-                elsif (!defined($base_queue)) {
-                    shift @edited_queues;
-                    push @add, $edited_queue;
-                }
-                # ran out of edited queues, remove base queue
-                elsif (!defined($edited_queue)) {
-                    shift @base_queues;
-                    push @remove, $base_queue;
-                }
-                # there's a base queue not in edited, so remove
-                elsif ($base_queue < $edited_queue) {
-                    shift @base_queues;
-                    push @remove, $base_queue;
-                }
-                # there's a edited queue not in base, so add
-                elsif ($edited_queue < $base_queue) {
-                    shift @edited_queues;
-                    push @add, $edited_queue;
-                }
-                else {
-                    die "Should never happen";
-                }
-            }
-
-            for my $id (@add) {
-                my $queue = RT::Queue->new(RT->SystemUser);
-                $queue->Load($id) if $id;
-                my $queue_name = $id ? "to queue " . $queue->Name : "globally";
-                update_record $cf, "add custom field $name $queue_name" => sub {
-                    $cf->AddToObject($queue);
-                };
-            }
-
-            for my $id (@remove) {
-                my $queue = RT::Queue->new(RT->SystemUser);
-                $queue->Load($id) if $id;
-                my $queue_name = $id ? "from queue " . $queue->Name : "globally";
-                update_record $cf, "remove custom field $name $queue_name" => sub {
-                    $cf->RemoveFromObject($queue);
-                };
-            }
-        }
+            _object_merger(
+                secondary_class => 'RT::Queue',
+                @_,
+            );
+        },
+    },
+    Classes => {
+        ApplyTo => sub {
+            _object_merger(
+                secondary_class => 'RT::Queue',
+                @_,
+            );
+        },
     },
 );
 

commit e699b0ba35723c9c32d8573be5b6e1fc28b17fc5
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Fri Mar 24 20:58:37 2017 +0000

    uniq ApplyTo objects, just in case

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index d56af0243..c7403d895 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -173,8 +173,8 @@ sub _object_merger {
        $_ = $secondary->Id;
    }
 
-   @base_objects = sort { $a <=> $b } @base_objects;
-   @edited_objects = sort { $a <=> $b } @edited_objects;
+   @base_objects = uniq sort { $a <=> $b } @base_objects;
+   @edited_objects = uniq sort { $a <=> $b } @edited_objects;
 
    my (@add, @remove);
 

commit 33c3a4a355980ca6f23cd07c5f4407add0052cea
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 16:55:40 2017 +0000

    Add FollowDisabled option to serializer

diff --git a/lib/RT/Migrate/Serializer.pm b/lib/RT/Migrate/Serializer.pm
index 50a27b94e..dccaa84cf 100644
--- a/lib/RT/Migrate/Serializer.pm
+++ b/lib/RT/Migrate/Serializer.pm
@@ -66,6 +66,7 @@ sub Init {
         AllUsers            => 1,
         AllGroups           => 1,
         FollowDeleted       => 1,
+        FollowDisabled      => 1,
 
         FollowScrips        => 0,
         FollowTickets       => 1,
@@ -86,6 +87,7 @@ sub Init {
                   AllUsers
                   AllGroups
                   FollowDeleted
+                  FollowDisabled
                   FollowScrips
                   FollowTickets
                   FollowAssets
@@ -197,7 +199,7 @@ sub PushCollections {
 
         $class->require or next;
         my $collection = $class->new( RT->SystemUser );
-        $collection->FindAllRows;   # be explicit
+        $collection->FindAllRows if $self->{FollowDisabled};
         $collection->CleanSlate;    # some collections (like groups and users) join in _Init
         $collection->UnLimit;
         $collection->OrderBy( FIELD => 'id' );
@@ -398,6 +400,15 @@ sub Process {
         return $self->Visit(%args);
     }
 
+    if (!$self->{FollowDisabled}) {
+        return if ($obj->can('Disabled') || $obj->_Accessible('Disabled', 'read'))
+               && $obj->Disabled
+
+               # Disabled for OCFV means "old value" which we want to keep
+               # in the history
+               && !$obj->isa('RT::ObjectCustomFieldValue');
+    }
+
     return $self->SUPER::Process( @_ );
 }
 

commit 232b6dbad5b77cacc0c2a0f65a41169f10adea78
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 18:42:53 2017 +0000

    Avoid serializing ACLs and GroupMembers if either parent is disabled

diff --git a/lib/RT/Migrate/Serializer.pm b/lib/RT/Migrate/Serializer.pm
index dccaa84cf..920d42a18 100644
--- a/lib/RT/Migrate/Serializer.pm
+++ b/lib/RT/Migrate/Serializer.pm
@@ -407,6 +407,18 @@ sub Process {
                # Disabled for OCFV means "old value" which we want to keep
                # in the history
                && !$obj->isa('RT::ObjectCustomFieldValue');
+
+        if ($obj->isa('RT::ACE')) {
+            my $principal = $obj->PrincipalObj;
+            return if $principal->Disabled;
+
+            # [issues.bestpractical.com #32662]
+            return if $principal->Object->Domain eq 'ACLEquivalence'
+                   && $principal->Object->InstanceObj->Disabled;
+
+            return if !$obj->Object->isa('RT::System')
+                   && $obj->Object->Disabled;
+        }
     }
 
     return $self->SUPER::Process( @_ );
@@ -458,6 +470,17 @@ sub Observe {
         return 0 if $obj->Status eq "deleted" and not $self->{FollowDeleted};
         return $self->{FollowAssets};
     } elsif ($obj->isa("RT::ACE")) {
+        if (!$self->{FollowDisabled}) {
+            my $principal = $obj->PrincipalObj;
+            return 0 if $principal->Disabled;
+
+            # [issues.bestpractical.com #32662]
+            return 0 if $principal->Object->Domain eq 'ACLEquivalence'
+                     && $principal->Object->InstanceObj->Disabled;
+
+            return 0 if !$obj->Object->isa('RT::System')
+                     && $obj->Object->Disabled;
+        }
         return $self->{FollowACL};
     } elsif ($obj->isa("RT::Scrip") or $obj->isa("RT::Template") or $obj->isa("RT::ObjectScrip")) {
         return $self->{FollowScrips};
@@ -468,6 +491,10 @@ sub Observe {
         } elsif ($grp->Domain eq "SystemInternal") {
             return 0 if $grp->UID eq $from;
         }
+        if (!$self->{FollowDisabled}) {
+            return 0 if $grp->Disabled
+                     || $obj->MemberObj->Disabled;
+        }
     }
 
     return 1;

commit 91891f4b49890d5deca6d05d999dfe0795693c5e
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 19:28:34 2017 +0000

    Avoid error on broken ACLEquivalence principals
    
    If you've deleted users directly from the database but haven't deleted
    their ACLEquivalence principal or group, then this was exploding trying
    to load a principal for an empty RT::User object

diff --git a/lib/RT/Migrate/Serializer.pm b/lib/RT/Migrate/Serializer.pm
index 920d42a18..7d933f304 100644
--- a/lib/RT/Migrate/Serializer.pm
+++ b/lib/RT/Migrate/Serializer.pm
@@ -414,7 +414,8 @@ sub Process {
 
             # [issues.bestpractical.com #32662]
             return if $principal->Object->Domain eq 'ACLEquivalence'
-                   && $principal->Object->InstanceObj->Disabled;
+                   && (!$principal->Object->InstanceObj->Id
+                     || $principal->Object->InstanceObj->Disabled);
 
             return if !$obj->Object->isa('RT::System')
                    && $obj->Object->Disabled;
@@ -475,8 +476,9 @@ sub Observe {
             return 0 if $principal->Disabled;
 
             # [issues.bestpractical.com #32662]
-            return 0 if $principal->Object->Domain eq 'ACLEquivalence'
-                     && $principal->Object->InstanceObj->Disabled;
+            return if $principal->Object->Domain eq 'ACLEquivalence'
+                   && (!$principal->Object->InstanceObj->Id
+                     || $principal->Object->InstanceObj->Disabled);
 
             return 0 if !$obj->Object->isa('RT::System')
                      && $obj->Object->Disabled;

commit 7ae4502e1b0e8215c909983e10b9d446543accf1
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Sat Aug 4 17:08:18 2018 +0800

    Sort OCFV values to keep the original id order

diff --git a/lib/RT/Migrate/Serializer/JSON.pm b/lib/RT/Migrate/Serializer/JSON.pm
index 7e228ffec..80d17a292 100644
--- a/lib/RT/Migrate/Serializer/JSON.pm
+++ b/lib/RT/Migrate/Serializer/JSON.pm
@@ -417,7 +417,7 @@ sub CanonicalizeObjectCustomFieldValues {
     my $self = shift;
 
     my $records = delete $self->{Records}{'RT::ObjectCustomFieldValue'};
-    for my $id (keys %$records) {
+    for my $id ( sort { $records->{$a}{id} <=> $records->{$b}{id} } keys %$records ) {
         my $record = $records->{$id};
 
         if ($record->{Disabled} && !$self->{FollowDisabled}) {

commit f5e17ed632d1150919f6383e6a3e34716e36f3b0
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Wed Mar 22 16:55:40 2017 +0000

    Add a --no-disabled flag to serializer

diff --git a/sbin/rt-serializer.in b/sbin/rt-serializer.in
index 79fea41c3..67749d0ed 100644
--- a/sbin/rt-serializer.in
+++ b/sbin/rt-serializer.in
@@ -99,6 +99,7 @@ GetOptions(
 
     "users!",
     "groups!",
+    "disabled!",
     "deleted!",
 
     "scrips!",
@@ -123,9 +124,10 @@ $args{Directory}   = $OPT{directory};
 $args{Force}       = $OPT{force};
 $args{MaxFileSize} = $OPT{size} if $OPT{size};
 
-$args{AllUsers}      = $OPT{users}    if defined $OPT{users};
-$args{AllGroups}     = $OPT{groups}   if defined $OPT{groups};
-$args{FollowDeleted} = $OPT{deleted}  if defined $OPT{deleted};
+$args{AllUsers}       = $OPT{users}    if defined $OPT{users};
+$args{AllGroups}      = $OPT{groups}   if defined $OPT{groups};
+$args{FollowDeleted}  = $OPT{deleted}  if defined $OPT{deleted};
+$args{FollowDisabled} = $OPT{disabled} if defined $OPT{disabled};
 
 $args{FollowScrips}  = $OPT{scrips}   if defined $OPT{scrips};
 $args{FollowTickets} = $OPT{tickets}  if defined $OPT{tickets};
@@ -181,7 +183,7 @@ if ($OPT{'limit-cfs'}) {
 }
 
 if (($OPT{clone} or $OPT{incremental})
-        and grep { /^(users|groups|deleted|scrips|tickets|acls|assets)$/ } keys %OPT) {
+        and grep { /^(users|groups|deleted|disabled|scrips|tickets|acls|assets)$/ } keys %OPT) {
     die "You cannot specify object types when cloning.\n\nPlease see $0 --help.\n";
 }
 
@@ -356,6 +358,12 @@ consistency.
 By default, all assets are serialized; passing C<--no-assets> skips
 assets during serialization.
 
+=item B<--no-disabled>
+
+By default, all queues, custom fields, etc, including disabled ones, are
+serialized; passing C<--no-disabled> skips such disabled records during
+serialization.
+
 =item B<--no-deleted>
 
 By default, all tickets and assets, including deleted ones, are

commit 87d4019049f227f6de6bac158935bf40a4fb0abc
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Mar 14 19:43:33 2017 +0000

    Add --no-transactions flag to rt-serializer

diff --git a/lib/RT/Migrate/Serializer.pm b/lib/RT/Migrate/Serializer.pm
index 7d933f304..03b38ad46 100644
--- a/lib/RT/Migrate/Serializer.pm
+++ b/lib/RT/Migrate/Serializer.pm
@@ -70,6 +70,7 @@ sub Init {
 
         FollowScrips        => 0,
         FollowTickets       => 1,
+        FollowTransactions  => 1,
         FollowACL           => 0,
         FollowAssets        => 1,
 
@@ -90,6 +91,7 @@ sub Init {
                   FollowDisabled
                   FollowScrips
                   FollowTickets
+                  FollowTransactions
                   FollowAssets
                   FollowACL
                   Queues
@@ -484,6 +486,8 @@ sub Observe {
                      && $obj->Object->Disabled;
         }
         return $self->{FollowACL};
+    } elsif ($obj->isa("RT::Transaction")) {
+        return $self->{FollowTransactions};
     } elsif ($obj->isa("RT::Scrip") or $obj->isa("RT::Template") or $obj->isa("RT::ObjectScrip")) {
         return $self->{FollowScrips};
     } elsif ($obj->isa("RT::GroupMember")) {
diff --git a/sbin/rt-serializer.in b/sbin/rt-serializer.in
index 67749d0ed..4fdd6092d 100644
--- a/sbin/rt-serializer.in
+++ b/sbin/rt-serializer.in
@@ -104,6 +104,7 @@ GetOptions(
 
     "scrips!",
     "tickets!",
+    "transactions!",
     "acls!",
     "limit-queues=s@",
     "limit-cfs=s@",
@@ -129,9 +130,10 @@ $args{AllGroups}      = $OPT{groups}   if defined $OPT{groups};
 $args{FollowDeleted}  = $OPT{deleted}  if defined $OPT{deleted};
 $args{FollowDisabled} = $OPT{disabled} if defined $OPT{disabled};
 
-$args{FollowScrips}  = $OPT{scrips}   if defined $OPT{scrips};
-$args{FollowTickets} = $OPT{tickets}  if defined $OPT{tickets};
-$args{FollowACL}     = $OPT{acls}     if defined $OPT{acls};
+$args{FollowScrips}       = $OPT{scrips}       if defined $OPT{scrips};
+$args{FollowTickets}      = $OPT{tickets}      if defined $OPT{tickets};
+$args{FollowTransactions} = $OPT{transactions} if defined $OPT{transactions};
+$args{FollowACL}          = $OPT{acls}         if defined $OPT{acls};
 
 $args{HyperlinkUnmigrated} = $OPT{'hyperlink-unmigrated'} if defined $OPT{'hyperlink-unmigrated'};
 
@@ -183,7 +185,7 @@ if ($OPT{'limit-cfs'}) {
 }
 
 if (($OPT{clone} or $OPT{incremental})
-        and grep { /^(users|groups|deleted|disabled|scrips|tickets|acls|assets)$/ } keys %OPT) {
+        and grep { /^(users|groups|deleted|disabled|scrips|tickets|transactions|acls|assets)$/ } keys %OPT) {
     die "You cannot specify object types when cloning.\n\nPlease see $0 --help.\n";
 }
 
@@ -402,6 +404,10 @@ The hyperlinks will use the serializing RT's configured URL.
 Without this option, such links are instead dropped, and transactions which
 had updated such links will be replaced with an explanatory message.
 
+=item B<--no-transactions>
+
+Skip serialization of all transactions on any records (not just tickets).
+
 =item B<--clone>
 
 Serializes your entire database, creating a clone.  This option should

commit 819e1d369cbe2de640362c1d1c1c6f026d01aa01
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Mon Aug 6 21:17:18 2018 +0800

    Initial rt-dump-initialdata

diff --git a/.gitignore b/.gitignore
index e6da7df18..4009d80a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@
 /sbin/rt-attributes-viewer
 /sbin/rt-clean-sessions
 /sbin/rt-dump-database
+/sbin/rt-dump-initialdata
 /sbin/rt-dump-metadata
 /sbin/rt-email-dashboards
 /sbin/rt-email-digest
diff --git a/Makefile.in b/Makefile.in
index f20edc946..cd615762e 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -140,6 +140,7 @@ BINARIES		=	$(RT_MAILGATE_BIN) \
 
 SYSTEM_BINARIES		=	rt-attributes-viewer \
 				rt-clean-sessions \
+				rt-dump-initialdata \
 				rt-dump-metadata \
 				rt-email-dashboards \
 				rt-email-digest \
diff --git a/configure.ac b/configure.ac
index 6eae1aac6..ae647a39b 100755
--- a/configure.ac
+++ b/configure.ac
@@ -467,6 +467,7 @@ AC_CONFIG_FILES([
                  sbin/rt-attributes-viewer
                  sbin/rt-preferences-viewer
                  sbin/rt-session-viewer
+                 sbin/rt-dump-initialdata
                  sbin/rt-dump-metadata
                  sbin/rt-setup-database
                  sbin/rt-test-dependencies
diff --git a/sbin/rt-dump-initialdata.in b/sbin/rt-dump-initialdata.in
new file mode 100644
index 000000000..af282797a
--- /dev/null
+++ b/sbin/rt-dump-initialdata.in
@@ -0,0 +1,356 @@
+#!@PERL@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+
+# fix lib paths, some may be relative
+BEGIN {
+    require File::Spec;
+    my @libs = ("@RT_LIB_PATH@", "@LOCAL_LIB_PATH@");
+    my $bin_path;
+
+    for my $lib (@libs) {
+        unless ( File::Spec->file_name_is_absolute($lib) ) {
+            unless ($bin_path) {
+                if ( File::Spec->file_name_is_absolute(__FILE__) ) {
+                    $bin_path = ( File::Spec->splitpath(__FILE__) )[1];
+                }
+                else {
+                    require FindBin;
+                    no warnings "once";
+                    $bin_path = $FindBin::Bin;
+                }
+            }
+            $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
+        }
+        unshift @INC, $lib;
+    }
+
+}
+
+use RT;
+RT::LoadConfig();
+RT::Init();
+
+ at RT::Record::ISA = qw( DBIx::SearchBuilder::Record RT::Base );
+
+use RT::Migrate;
+use RT::Migrate::Serializer::JSON;
+use Getopt::Long;
+use Pod::Usage qw//;
+use Time::HiRes qw//;
+
+my %OPT;
+GetOptions(
+    \%OPT,
+    "help|?",
+    "verbose|v!",
+    "quiet|q!",
+
+    "directory|d=s",
+    "force|f!",
+    "size|s=i",
+
+    "users!",
+    "groups!",
+    "deleted!",
+
+    "scrips!",
+    "acls!",
+    "assets!",
+
+    "sync",
+
+    "gc=i",
+    "page=i",
+) or Pod::Usage::pod2usage();
+
+Pod::Usage::pod2usage(-verbose => 1) if $OPT{help};
+
+my %args;
+$args{Directory}   = $OPT{directory};
+$args{Force}       = $OPT{force};
+$args{MaxFileSize} = $OPT{size} if $OPT{size};
+
+$args{AllUsers}       = $OPT{users}    if defined $OPT{users};
+$args{AllGroups}      = $OPT{groups}   if defined $OPT{groups};
+$args{FollowDeleted}  = $OPT{deleted}  if defined $OPT{deleted};
+$args{FollowDisabled} = $OPT{disabled} if defined $OPT{disabled};
+
+$args{FollowScrips}  = $OPT{scrips}   if defined $OPT{scrips};
+$args{FollowACL}     = $OPT{acls}     if defined $OPT{acls};
+
+$args{FollowAssets} = $OPT{assets} if defined $OPT{assets};
+
+$args{Sync} = $OPT{sync} if $OPT{sync};
+
+$args{GC}   = defined $OPT{gc}   ? $OPT{gc}   : 5000;
+$args{Page} = defined $OPT{page} ? $OPT{page} : 100;
+
+my $walker;
+
+my $gnuplot = `which gnuplot`;
+my $msg = "";
+if (-t STDOUT and not $OPT{verbose} and not $OPT{quiet}) {
+    $args{Progress} = RT::Migrate::progress(
+        top    => \&gnuplot,
+        bottom => sub { print "\n$msg"; $msg = ""; },
+        counts => sub { $walker->ObjectCount },
+        bars   => [
+            qw/Queue User Group GroupMember Attribute CustomField CustomFieldValue
+              ObjectCustomField ObjectCustomFieldValue Catalog Asset ACE CustomRole
+              Class Article ScripAction ScripCondition Template Scrip/
+        ],
+        max => { estimate() },
+    );
+    $args{MessageHandler} = sub {
+        print "\r", " "x60, "\r", $_[-1]; $msg = $_[-1];
+    };
+    $args{Verbose}  = 0;
+}
+$args{Verbose} = 0 if $OPT{quiet};
+
+
+$walker = RT::Migrate::Serializer::JSON->new( FollowTickets => 0, FollowTransactions => 0, %args );
+
+my $log = RT::Migrate::setup_logging( $walker->{Directory} => 'initialdata.log' );
+print "Logging warnings and errors to $log\n" if $log;
+
+print "Beginning dumping initialdata...";
+my %counts = $walker->Export;
+
+my @files = $walker->Files;
+print "Wrote @{[scalar @files]} files:\n";
+print "    $_\n" for @files;
+print "\n";
+
+print "Total object counts:\n";
+for (sort {$counts{$b} <=> $counts{$a}} keys %counts) {
+    printf "%8d %s\n", $counts{$_}, $_;
+}
+
+if ($log and -s $log) {
+    print STDERR "\n! Some warnings or errors occurred during initialdata dumping."
+                ."\n! Please see $log for details.\n\n";
+} else {
+    unlink $log;
+}
+
+sub estimate {
+    $| = 1;
+    my %e;
+
+    # Expected types we'll serialize
+    my @types = map { "RT::$_" } qw/
+      Queue User Group GroupMember Attribute CustomField CustomFieldValue
+      ObjectCustomField ObjectCustomFieldValue Catalog Asset ACE CustomRole
+      Class Article ScripAction ScripCondition Template Scrip/;
+
+    for my $class (@types) {
+        print "Estimating $class count...";
+        my $collection;
+        if ( $class eq 'RT::ACE' ) {
+            $collection = 'RT::ACL';
+        }
+        else {
+            $collection = $class . ( UNIVERSAL::can( $class . 'es', 'new' ) ? 'es' : 's' );
+        }
+
+        if ($collection->require) {
+            my $objs = $collection->new( RT->SystemUser );
+            $objs->FindAllRows;
+            $objs->UnLimit;
+            $objs->{allow_deleted_search} = 1 if $class eq "RT::Asset";
+            $e{$class} = $objs->DBIx::SearchBuilder::Count;
+        }
+        print "\r", " "x60, "\r";
+    }
+
+    return %e;
+}
+
+
+sub gnuplot {
+    my ($elapsed, $rows, $cols) = @_;
+    my $length = $walker->StackSize;
+    my $file = $walker->Directory . "/progress.plot";
+    open(my $dat, ">>", $file);
+    printf $dat "%10.3f\t%8d\n", $elapsed, $length;
+    close $dat;
+
+    if ($rows <= 24 or not $gnuplot) {
+        print "\n\n";
+    } elsif ($elapsed) {
+        my $gnuplot = qx|
+            gnuplot -e '
+                set term dumb $cols @{[$rows - 12]};
+                set xlabel "Seconds";
+                unset key;
+                set xrange [0:*];
+                set yrange [0:*];
+                set title "Queue length";
+                plot "$file" using 1:2 with lines
+            '
+        |;
+        if ($? == 0 and $gnuplot) {
+            $gnuplot =~ s/^(\s*\n)//;
+            print $gnuplot;
+            unlink $file;
+        } else {
+            warn "Couldn't run gnuplot (\$? == $?): $!\n";
+        }
+    } else {
+        print "\n" for 1..($rows - 13);
+    }
+}
+
+=head1 NAME
+
+rt-dump-initialdata - Serialize an RT database to disk
+
+=head1 SYNOPSIS
+
+    rt-validator --check && rt-dump-initialdata
+
+This script is used to write out the objects initialdata supports from
+RT database to disk, for later import into a different RT instance.  It
+requires that the data in the database be self-consistent, in order to
+do so; please make sure that the database being exported passes
+validation by L<rt-validator> before attempting to use
+C<rt-dump-initialdata>.
+
+While running, it will attempt to estimate the number of remaining
+objects to be dumped; these estimates are pessimistic, and will be
+incorrect if C<--no-users> or C<--no-groups> is used.
+
+If the controlling terminal is large enough (more than 25 columns high)
+and the C<gnuplot> program is installed, it will also show a textual
+graph of the queue size over time.
+
+=head2 OPTIONS
+
+=over
+
+=item B<--directory> I<name>
+
+The name of the output directory to write data files to, which should
+not exist yet; it is a fatal error if it does.  Defaults to
+C<< ./I<$Organization>:I<Date>/ >>, where I<$Organization> is as set in
+F<RT_SiteConfig.pm>, and I<Date> is today's date.
+
+=item B<--force>
+
+Remove the output directory before starting.
+
+=item B<--no-users>
+
+By default, all privileged users are dumped; passing C<--no-users>
+limits it to only those users which are referenced by dumped tickets
+and history, and are thus necessary for internal consistency.
+
+=item B<--no-groups>
+
+By default, all groups are dumped; passing C<--no-groups> limits it
+to only system-internal groups, which are needed for internal
+consistency.
+
+=item B<--no-assets>
+
+By default, all assets are dumped; passing C<--no-assets> skips
+assets during serialization.
+
+=item B<--no-disabled>
+
+By default, all queues, custom fields, etc, including disabled ones, are
+dumped; passing C<--no-disabled> skips such disabled records during
+serialization.
+
+=item B<--no-deleted>
+
+By default, all assets, including deleted ones, are dumped; passing
+C<--no-deleted> skips deleted assets.
+
+=item B<--no-scrips>
+
+By default, all scrips and templates are dumped; passing C<--no-scrips>
+skips them.
+
+=item B<--no-acls>
+
+By default, all ACLs are dumped; passing C<--no-acls> skips them.
+
+=item B<--sync>
+
+By default, record ids are ordinarily excluded. Pass C<--sync> to
+include record ids if you intend to use this for sync rather than
+creating a generic initialdata.
+
+=item B<--gc> I<n>
+
+Adjust how often the garbage collection sweep is done; lower numbers are
+more frequent.  It shares the same code with C<rt-serializer>, See
+L<rt-serializer/GARBAGE COLLECTION>.
+
+=item B<--page> I<n>
+
+Adjust how many rows are pulled from the database in a single query.  Disable
+paging by setting this to 0.  Defaults to 100.
+
+=item B<--quiet>
+
+Do not show graphical progress UI.
+
+=item B<--verbose>
+
+Do not show graphical progress UI, but rather log was each row is
+written out.
+
+=back
+
+=cut

commit 45725576938d9dc2f0412100bea3aff4c3db56ab
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Mon Aug 6 21:39:20 2018 +0800

    Make label column a bit wider considering we have ObjectCustomFieldValues now

diff --git a/lib/RT/Migrate.pm b/lib/RT/Migrate.pm
index 50251b023..3746b7238 100644
--- a/lib/RT/Migrate.pm
+++ b/lib/RT/Migrate.pm
@@ -80,10 +80,10 @@ sub progress_bar {
 
     my $fraction = $args{max} ? $args{now} / $args{max} : 0;
 
-    my $max_width = $args{cols} - 30;
+    my $max_width = $args{cols} - 35;
     my $bar_width = int($max_width * $fraction);
 
-    return sprintf "%20s |%-" . $max_width . "s| %3d%%\n",
+    return sprintf "%25s |%-" . $max_width . "s| %3d%%\n",
         $args{label}, $args{char} x $bar_width, $fraction*100;
 }
 
@@ -163,9 +163,9 @@ sub progress {
             }
         }
         print "\n";
-        printf "%20s %s\n", "Elapsed time:",
+        printf "%25s %s\n", "Elapsed time:",
             format_time($elapsed);
-        printf "%20s %s\n", "Estimated left:",
+        printf "%25s %s\n", "Estimated left:",
             (defined $left) ? format_time($left) : "-";
 
         $args{bottom}->($elapsed, $rows, $cols);

commit eefd72603fbe4b9db751ea7e1b541a4d7c29000e
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Aug 7 00:05:48 2018 +0800

    Fix plural of words like "Class" in the progress bar of migration

diff --git a/lib/RT/Migrate.pm b/lib/RT/Migrate.pm
index 3746b7238..84d83a910 100644
--- a/lib/RT/Migrate.pm
+++ b/lib/RT/Migrate.pm
@@ -129,8 +129,9 @@ sub progress {
 
         my %counts = $args{counts}->();
         for my $class (map {"RT::$_"} @{$args{bars}}) {
+            my $suffix = UNIVERSAL::can( $class . 'es', 'new' ) ? 'es' : 's';
             my $display = $class;
-            $display =~ s/^RT::(.*)/@{[$1]}s:/;
+            $display =~ s/^RT::(.*)/@{[$1]}$suffix:/;
             print progress_bar(
                 label => $display,
                 now   => $counts{$class},

commit 1540433d485df593bac16e2dea84814efc7a751c
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Aug 7 00:22:39 2018 +0800

    Fix plural of "ACE" in the progress bar of migration

diff --git a/lib/RT/Migrate.pm b/lib/RT/Migrate.pm
index 84d83a910..71e37e9e2 100644
--- a/lib/RT/Migrate.pm
+++ b/lib/RT/Migrate.pm
@@ -129,9 +129,16 @@ sub progress {
 
         my %counts = $args{counts}->();
         for my $class (map {"RT::$_"} @{$args{bars}}) {
-            my $suffix = UNIVERSAL::can( $class . 'es', 'new' ) ? 'es' : 's';
-            my $display = $class;
-            $display =~ s/^RT::(.*)/@{[$1]}$suffix:/;
+            my $display;
+            if ( $class eq 'RT::ACE' ) {
+                $display = 'ACL:';
+            }
+            else {
+                $display = $class;
+                my $suffix = UNIVERSAL::can( $class . 'es', 'new' ) ? 'es' : 's';
+                $display =~ s/^RT::(.*)/@{[$1]}$suffix:/;
+            }
+
             print progress_bar(
                 label => $display,
                 now   => $counts{$class},

commit 2f0127eb5f931aefbd35d32200bb76255cd18fcf
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Aug 7 02:27:03 2018 +0800

    Fix UTF-8 decoding issue in rt-merge-initialdata
    
    JSON files are UTF-8 encoded, so we need to decode it like what we
    decode initialdata in RT::Handle.

diff --git a/sbin/rt-merge-initialdata.in b/sbin/rt-merge-initialdata.in
index c7403d895..cbc5c887d 100644
--- a/sbin/rt-merge-initialdata.in
+++ b/sbin/rt-merge-initialdata.in
@@ -425,7 +425,7 @@ sub is_deeply_equal {
 sub slurp_json {
     my $file = shift;
     local $/;
-    open (my $f, '<', $file)
+    open (my $f, '<encoding(UTF-8)', $file)
         or die "Cannot open initialdata file '$file' for read: $@";
     return $JSON->decode(scalar <$f>);
 }

commit 974e1e24f9cf6b8214877f81b9dadbcddea98631
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Mon Mar 20 20:56:10 2017 +0000

    First initialdata roundtrip tests

diff --git a/t/api/initialdata-roundtrip.t b/t/api/initialdata-roundtrip.t
new file mode 100644
index 000000000..8098284af
--- /dev/null
+++ b/t/api/initialdata-roundtrip.t
@@ -0,0 +1,1121 @@
+use utf8;
+use strict;
+use warnings;
+use JSON;
+
+use RT::Test tests => undef, config => << 'CONFIG';
+Plugin('RT::Extension::Initialdata::JSON');
+Set($InitialdataFormatHandlers, [ 'perl', 'RT::Extension::Initialdata::JSON' ]);
+CONFIG
+
+my $general = RT::Queue->new(RT->SystemUser);
+$general->Load('General');
+
+my @tests = (
+    {
+        name => 'Simple user-defined group',
+        create => sub {
+            my $group = RT::Group->new(RT->SystemUser);
+            my ($ok, $msg) = $group->CreateUserDefinedGroup(Name => 'Staff');
+            ok($ok, $msg);
+        },
+        absent => sub {
+            my $group = RT::Group->new(RT->SystemUser);
+            $group->LoadUserDefinedGroup('Staff');
+            ok(!$group->Id, 'No such group');
+        },
+        present => sub {
+            my $group = RT::Group->new(RT->SystemUser);
+            $group->LoadUserDefinedGroup('Staff');
+            ok($group->Id, 'Loaded group');
+            is($group->Name, 'Staff', 'Group name');
+            is($group->Domain, 'UserDefined', 'Domain');
+        },
+    },
+    {
+        name => 'Group membership and ACLs',
+        create => sub {
+            my $outer = RT::Group->new(RT->SystemUser);
+            my ($ok, $msg) = $outer->CreateUserDefinedGroup(Name => 'Outer');
+            ok($ok, $msg);
+
+            my $inner = RT::Group->new(RT->SystemUser);
+            ($ok, $msg) = $inner->CreateUserDefinedGroup(Name => 'Inner');
+            ok($ok, $msg);
+
+            my $unrelated = RT::Group->new(RT->SystemUser);
+            ($ok, $msg) = $unrelated->CreateUserDefinedGroup(Name => 'Unrelated');
+            ok($ok, $msg);
+
+            my $user = RT::User->new(RT->SystemUser);
+            ($ok, $msg) = $user->Create(Name => 'User');
+            ok($ok, $msg);
+
+            my $unprivileged = RT::User->new(RT->SystemUser);
+            ($ok, $msg) = $unprivileged->Create(Name => 'Unprivileged');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $outer->AddMember($inner->PrincipalId);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $inner->AddMember($user->PrincipalId);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $general->AddWatcher(Type => 'AdminCc', PrincipalId => $outer->PrincipalId);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $general->AdminCc->PrincipalObj->GrantRight(Object => $general, Right => 'ShowTicket');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $inner->PrincipalObj->GrantRight(Object => $general, Right => 'ModifyTicket');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $user->PrincipalObj->GrantRight(Object => $general, Right => 'OwnTicket');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $unprivileged->PrincipalObj->GrantRight(Object => RT->System, Right => 'ModifyTicket');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $inner->PrincipalObj->GrantRight(Object => $inner, Right => 'SeeGroup');
+            ok($ok, $msg);
+
+        },
+        present => sub {
+            my $outer = RT::Group->new(RT->SystemUser);
+            $outer->LoadUserDefinedGroup('Outer');
+            ok($outer->Id, 'Loaded group');
+            is($outer->Name, 'Outer', 'Group name');
+
+            my $inner = RT::Group->new(RT->SystemUser);
+            $inner->LoadUserDefinedGroup('Inner');
+            ok($inner->Id, 'Loaded group');
+            is($inner->Name, 'Inner', 'Group name');
+
+            my $unrelated = RT::Group->new(RT->SystemUser);
+            $unrelated->LoadUserDefinedGroup('Unrelated');
+            ok($unrelated->Id, 'Loaded group');
+            is($unrelated->Name, 'Unrelated', 'Group name');
+
+            my $user = RT::User->new(RT->SystemUser);
+            $user->Load('User');
+            ok($user->Id, 'Loaded user');
+            is($user->Name, 'User', 'User name');
+
+            my $unprivileged = RT::User->new(RT->SystemUser);
+            $unprivileged->Load('Unprivileged');
+            ok($unprivileged->Id, 'Loaded Unprivileged');
+            is($unprivileged->Name, 'Unprivileged', 'Unprivileged name');
+
+            ok($outer->HasMember($inner->PrincipalId), 'outer hasmember inner');
+            ok($inner->HasMember($user->PrincipalId), 'inner hasmember user');
+            ok($outer->HasMemberRecursively($user->PrincipalId), 'outer hasmember user recursively');
+            ok(!$outer->HasMember($user->PrincipalId), 'outer does not have member user directly');
+            ok(!$inner->HasMember($outer->PrincipalId), 'inner does not have member outer');
+
+            ok($general->AdminCc->HasMember($outer->PrincipalId), 'queue AdminCc');
+            ok($general->AdminCc->HasMemberRecursively($inner->PrincipalId), 'queue AdminCc');
+            ok($general->AdminCc->HasMemberRecursively($user->PrincipalId), 'queue AdminCc');
+
+            ok(!$outer->HasMemberRecursively($unrelated->PrincipalId), 'unrelated group membership');
+            ok(!$inner->HasMemberRecursively($unrelated->PrincipalId), 'unrelated group membership');
+            ok(!$general->AdminCc->HasMemberRecursively($unrelated->PrincipalId), 'unrelated group membership');
+
+            ok($general->AdminCc->PrincipalObj->HasRight(Object => $general, Right => 'ShowTicket'), 'AdminCc ShowTicket right');
+            ok($outer->PrincipalObj->HasRight(Object => $general, Right => 'ShowTicket'), 'outer ShowTicket right');
+            ok($inner->PrincipalObj->HasRight(Object => $general, Right => 'ShowTicket'), 'inner ShowTicket right');
+            ok($user->PrincipalObj->HasRight(Object => $general, Right => 'ShowTicket'), 'user ShowTicket right');
+            ok(!$unrelated->PrincipalObj->HasRight(Object => $general, Right => 'ShowTicket'), 'unrelated ShowTicket right');
+
+            ok(!$general->AdminCc->PrincipalObj->HasRight(Object => $general, Right => 'ModifyTicket'), 'AdminCc ModifyTicket right');
+            ok(!$outer->PrincipalObj->HasRight(Object => $general, Right => 'ModifyTicket'), 'outer ModifyTicket right');
+            ok($inner->PrincipalObj->HasRight(Object => $general, Right => 'ModifyTicket'), 'inner ModifyTicket right');
+            ok($user->PrincipalObj->HasRight(Object => $general, Right => 'ModifyTicket'), 'user ModifyTicket right');
+            ok(!$unrelated->PrincipalObj->HasRight(Object => $general, Right => 'ModifyTicket'), 'unrelated ModifyTicket right');
+
+            ok(!$general->AdminCc->PrincipalObj->HasRight(Object => $general, Right => 'OwnTicket'), 'AdminCc OwnTicket right');
+            ok(!$outer->PrincipalObj->HasRight(Object => $general, Right => 'OwnTicket'), 'outer OwnTicket right');
+            ok(!$inner->PrincipalObj->HasRight(Object => $general, Right => 'OwnTicket'), 'inner OwnTicket right');
+            ok($user->PrincipalObj->HasRight(Object => $general, Right => 'OwnTicket'), 'inner OwnTicket right');
+            ok(!$unrelated->PrincipalObj->HasRight(Object => $general, Right => 'OwnTicket'), 'unrelated OwnTicket right');
+
+            ok($unprivileged->PrincipalObj->HasRight(Object => RT->System, Right => 'ModifyTicket'), 'unprivileged ModifyTicket right');
+
+            ok(!$general->AdminCc->PrincipalObj->HasRight(Object => $inner, Right => 'SeeGroup'), 'AdminCc SeeGroup right');
+            ok(!$outer->PrincipalObj->HasRight(Object => $inner, Right => 'SeeGroup'), 'outer SeeGroup right');
+            ok($inner->PrincipalObj->HasRight(Object => $inner, Right => 'SeeGroup'), 'inner SeeGroup right');
+            ok($user->PrincipalObj->HasRight(Object => $inner, Right => 'SeeGroup'), 'user SeeGroup right');
+            ok(!$unrelated->PrincipalObj->HasRight(Object => $inner, Right => 'SeeGroup'), 'unrelated SeeGroup right');
+        },
+    },
+
+    {
+        name => 'Custom field on two queues',
+        create => sub {
+            my $bugs = RT::Queue->new(RT->SystemUser);
+            my ($ok, $msg) = $bugs->Create(Name => 'Bugs');
+            ok($ok, $msg);
+
+            my $features = RT::Queue->new(RT->SystemUser);
+            ($ok, $msg) = $features->Create(Name => 'Features');
+            ok($ok, $msg);
+
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $cf->Create(
+                Name => 'Fixed In',
+                Type => 'SelectSingle',
+                LookupType => RT::Ticket->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+
+            ($ok, $msg) = $cf->AddToObject($bugs);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $cf->AddToObject($features);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $cf->AddValue(Name => '0.1', Description => 'Prototype', SortOrder => '1');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $cf->AddValue(Name => '1.0', Description => 'Gold', SortOrder => '10');
+            ok($ok, $msg);
+
+            # these next two are intentionally added in an order different from their SortOrder
+            ($ok, $msg) = $cf->AddValue(Name => '2.0', Description => 'Remaster', SortOrder => '20');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $cf->AddValue(Name => '1.1', Description => 'Gold Bugfix', SortOrder => '11');
+            ok($ok, $msg);
+
+        },
+        present => sub {
+            my $bugs = RT::Queue->new(RT->SystemUser);
+            $bugs->Load('Bugs');
+            ok($bugs->Id, 'Bugs queue loaded');
+            is($bugs->Name, 'Bugs');
+
+            my $features = RT::Queue->new(RT->SystemUser);
+            $features->Load('Features');
+            ok($features->Id, 'Features queue loaded');
+            is($features->Name, 'Features');
+
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            $cf->Load('Fixed In');
+            ok($cf->Id, 'Fixed In CF loaded');
+            is($cf->Name, 'Fixed In');
+            is($cf->Type, 'Select', 'Type');
+            is($cf->MaxValues, 1, 'MaxValues');
+            is($cf->LookupType, RT::Ticket->CustomFieldLookupType, 'LookupType');
+
+            ok($cf->IsAdded($bugs->Id), 'CF is on Bugs queue');
+            ok($cf->IsAdded($features->Id), 'CF is on Features queue');
+            ok(!$cf->IsAdded(0), 'CF is not global');
+            ok(!$cf->IsAdded($general->Id), 'CF is not on General queue');
+
+            my @values = map { {
+                Name => $_->Name,
+                Description => $_->Description,
+                SortOrder => $_->SortOrder,
+            } } @{ $cf->Values->ItemsArrayRef };
+
+            is_deeply(\@values, [
+                { Name => '0.1', Description => 'Prototype', SortOrder => '1' },
+                { Name => '1.0', Description => 'Gold', SortOrder => '10' },
+                { Name => '1.1', Description => 'Gold Bugfix', SortOrder => '11' },
+                { Name => '2.0', Description => 'Remaster', SortOrder => '20' },
+            ], 'CF values');
+        },
+    },
+
+    {
+        name => 'Custom field lookup types',
+        create => sub {
+            my %extra = (
+                Group => { method => 'CreateUserDefinedGroup' },
+                Asset => undef,
+                Article => { Class => 'General' },
+                Ticket => undef,
+                Transaction => undef,
+                User => undef,
+            );
+
+            for my $type (qw/Asset Article Group Queue Ticket Transaction User/) {
+                my $class = "RT::$type";
+                my $cf = RT::CustomField->new(RT->SystemUser);
+                my ($ok, $msg) = $cf->Create(
+                    Name => "$type CF",
+                    Type => "FreeformSingle",
+                    LookupType => $class->CustomFieldLookupType,
+                );
+                ok($ok, $msg);
+
+                # apply globally
+                ($ok, $msg) = $cf->AddToObject($cf->RecordClassFromLookupType->new(RT->SystemUser));
+                ok($ok, $msg);
+
+                next if exists($extra{$type}) && !defined($extra{$type});
+
+                my $obj = $class->new(RT->SystemUser);
+                my $method = delete($extra{$type}{method}) || 'Create';
+                ($ok, $msg) = $obj->$method(
+                    Name => $type,
+                    %{ $extra{$type} || {} },
+                );
+                ok($ok, "created $type: $msg");
+                ok($obj->Id, "loaded $type");
+
+                ($ok, $msg) = $obj->AddCustomFieldValue(
+                    Field => $cf->Id,
+                    Value => "$type Value",
+                );
+                ok($ok, $msg);
+            }
+        },
+        present => sub {
+            my %load = (
+                Transaction => undef,
+                Ticket => undef,
+                User => undef,
+                Asset => undef,
+            );
+
+            for my $type (qw/Asset Article Group Queue Ticket Transaction User/) {
+                my $class = "RT::$type";
+                my $cf = RT::CustomField->new(RT->SystemUser);
+                $cf->Load("$type CF");
+                ok($cf->Id, "loaded $type CF");
+                is($cf->Name, "$type CF", 'Name');
+                is($cf->Type, 'Freeform', 'Type');
+                is($cf->MaxValues, 1, 'MaxValues');
+                is($cf->LookupType, $class->CustomFieldLookupType, 'LookupType');
+
+                next if exists($load{$type}) && !defined($load{$type});
+
+                my $obj = $class->new(RT->SystemUser);
+                $obj->LoadByCols(
+                    %{ $load{$type} || { Name => $type } },
+                );
+                ok($obj->Id, "loaded $type");
+
+                is($obj->FirstCustomFieldValue($cf->Id), "$type Value", "CF value for $type");
+            }
+        },
+    },
+
+    {
+        name => 'Custom field LargeContent',
+        create => sub {
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            my ($ok, $msg) = $cf->Create(
+                Name => "Group CF",
+                Type => "FreeformSingle",
+                LookupType => RT::Group->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+
+            ($ok, $msg) = $cf->AddToObject(RT::Group->new(RT->SystemUser));
+            ok($ok, $msg);
+
+            my $group = RT::Group->new(RT->SystemUser);
+            ($ok, $msg) = $group->CreateUserDefinedGroup(Name => 'Group');
+            ok($ok, $msg);
+
+            ($ok, $msg) = $group->AddCustomFieldValue(
+                Field => $cf->Id,
+                Value => scalar("abc" x 256),
+            );
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $group = RT::Group->new(RT->SystemUser);
+            $group->LoadUserDefinedGroup('Group');
+            ok($group->Id, 'loaded Group');
+            is($group->FirstCustomFieldValue('Group CF'), scalar("abc" x 256), "CF LargeContent");
+        },
+        # the following test peers into the initialdata only to make sure that
+        # we are roundtripping LargeContent as expected; if this starts
+        # failing it's not necessarily a problem, but it's worthy of
+        # investigating whether the "present" tests are still testing
+        # what they were meant to test
+        raw => sub {
+            my $json = shift;
+            my ($group) = grep { $_->{Name} eq 'Group' } @{ $json->{Groups} };
+            ok($group, 'found the group');
+            my ($ocfv) = @{ $group->{CustomFields} };
+            ok($ocfv, 'found the OCFV');
+
+            is($ocfv->{CustomField}, 'Group CF', 'CustomField');
+            is($ocfv->{Content}, undef, 'no Content');
+            is($ocfv->{LargeContent}, scalar("abc" x 256), 'LargeContent');
+            is($ocfv->{ContentType}, "text/plain", 'ContentType');
+        }
+    },
+
+    {
+        name => 'Scrips including Disabled',
+        export_args => { FollowDisabled => 1 },
+        create => sub {
+            my $bugs = RT::Queue->new(RT->SystemUser);
+            my ($ok, $msg) = $bugs->Create(Name => 'Bugs');
+            ok($ok, $msg);
+
+            my $features = RT::Queue->new(RT->SystemUser);
+            ($ok, $msg) = $features->Create(Name => 'Features');
+            ok($ok, $msg);
+
+            my $disabled = RT::Scrip->new(RT->SystemUser);
+            ($ok, $msg) = $disabled->Create(
+                Queue => 0,
+                Description => 'Disabled Scrip',
+                Template => 'Blank',
+                ScripCondition => 'User Defined',
+                ScripAction => 'User Defined',
+                CustomIsApplicableCode => 'return "condition"',
+                CustomPrepareCode => 'return "prepare"',
+                CustomCommitCode => 'return "commit"',
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $disabled->SetDisabled(1);
+            ok($ok, $msg);
+
+            my $stages = RT::Scrip->new(RT->SystemUser);
+            ($ok, $msg) = $stages->Create(
+                Description => 'Staged Scrip',
+                Template => 'Transaction',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+
+            ($ok, $msg) = $stages->RemoveFromObject(0);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $stages->AddToObject(
+                ObjectId  => $bugs->Id,
+                Stage     => 'TransactionBatch',
+                SortOrder => 42,
+            );
+            ok($ok, $msg);
+
+            ($ok, $msg) = $stages->AddToObject(
+                ObjectId  => $features->Id,
+                Stage     => 'TransactionCreate',
+                SortOrder => 99,
+            );
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $bugs = RT::Queue->new(RT->SystemUser);
+            $bugs->Load('Bugs');
+            ok($bugs->Id, 'Bugs queue loaded');
+            is($bugs->Name, 'Bugs');
+
+            my $features = RT::Queue->new(RT->SystemUser);
+            $features->Load('Features');
+            ok($features->Id, 'Features queue loaded');
+            is($features->Name, 'Features');
+
+            my $disabled = RT::Scrip->new(RT->SystemUser);
+            $disabled->LoadByCols(Description => 'Disabled Scrip');
+            ok($disabled->Id, 'Disabled scrip loaded');
+            is($disabled->Description, 'Disabled Scrip', 'Description');
+            is($disabled->Template, 'Blank', 'Template');
+            is($disabled->ConditionObj->Name, 'User Defined', 'Condition');
+            is($disabled->ActionObj->Name, 'User Defined', 'Action');
+            is($disabled->CustomIsApplicableCode, 'return "condition"', 'Condition code');
+            is($disabled->CustomPrepareCode, 'return "prepare"', 'Prepare code');
+            is($disabled->CustomCommitCode, 'return "commit"', 'Commit code');
+            ok($disabled->Disabled, 'Disabled');
+            ok($disabled->IsGlobal, 'IsGlobal');
+
+            my $stages = RT::Scrip->new(RT->SystemUser);
+            $stages->LoadByCols(Description => 'Staged Scrip');
+            ok($stages->Id, 'Staged scrip loaded');
+            is($stages->Description, 'Staged Scrip');
+            ok(!$stages->Disabled, 'not Disabled');
+            ok(!$stages->IsGlobal, 'not Global');
+
+            my $bug_objectscrip = $stages->IsAdded($bugs->Id);
+            ok($bug_objectscrip, 'added to Bugs');
+            is($bug_objectscrip->Stage, 'TransactionBatch', 'Stage');
+            is($bug_objectscrip->SortOrder, 42, 'SortOrder');
+
+            my $features_objectscrip = $stages->IsAdded($features->Id);
+            ok($features_objectscrip, 'added to Features');
+            is($features_objectscrip->Stage, 'TransactionCreate', 'Stage');
+            is($features_objectscrip->SortOrder, 99, 'SortOrder');
+
+            ok(!$stages->IsAdded($general->Id), 'not added to General');
+        },
+    },
+
+    {
+        name => 'No disabled scrips',
+        create => sub {
+            my $disabled = RT::Scrip->new(RT->SystemUser);
+            my ($ok, $msg) = $disabled->Create(
+                Description => 'Disabled Scrip',
+                Template => 'Transaction',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $disabled->SetDisabled(1);
+            ok($ok, $msg);
+
+            my $enabled = RT::Scrip->new(RT->SystemUser);
+            ($ok, $msg) = $enabled->Create(
+                Description => 'Enabled Scrip',
+                Template => 'Transaction',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $from_initialdata = shift;
+
+            my $disabled = RT::Scrip->new(RT->SystemUser);
+            $disabled->LoadByCols(Description => 'Disabled Scrip');
+
+            if ($from_initialdata) {
+                ok(!$disabled->Id, 'Disabled scrip absent in initialdata');
+            }
+            else {
+                ok($disabled->Id, 'Disabled scrip present because of the original creation');
+                ok($disabled->Disabled, 'Disabled scrip disabled');
+            }
+
+            my $enabled = RT::Scrip->new(RT->SystemUser);
+            $enabled->LoadByCols(Description => 'Enabled Scrip');
+            ok($enabled->Id, 'Enabled scrip present');
+        },
+    },
+
+    {
+        name => 'Disabled many-to-many relationships',
+        create => sub {
+            my $enabled_queue = RT::Queue->new(RT->SystemUser);
+            my ($ok, $msg) = $enabled_queue->Create(
+                Name => 'Enabled Queue',
+            );
+            ok($ok, $msg);
+
+            my $disabled_queue = RT::Queue->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_queue->Create(
+                Name => 'Disabled Queue',
+            );
+            ok($ok, $msg);
+
+            my $enabled_cf = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $enabled_cf->Create(
+                Name => 'Enabled CF',
+                Type => 'FreeformSingle',
+                LookupType => RT::Queue->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+
+            my $disabled_cf = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_cf->Create(
+                Name => 'Disabled CF',
+                Type => 'FreeformSingle',
+                LookupType => RT::Queue->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+
+            my $enabled_scrip = RT::Scrip->new(RT->SystemUser);
+            ($ok, $msg) = $enabled_scrip->Create(
+                Queue => 0,
+                Description => 'Enabled Scrip',
+                Template => 'Blank',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+            $enabled_scrip->RemoveFromObject(0);
+
+            my $disabled_scrip = RT::Scrip->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_scrip->Create(
+                Queue => 0,
+                Description => 'Disabled Scrip',
+                Template => 'Blank',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+            $disabled_scrip->RemoveFromObject(0);
+
+            my $enabled_class = RT::Class->new(RT->SystemUser);
+            ($ok, $msg) = $enabled_class->Create(
+                Name => 'Enabled Class',
+            );
+            ok($ok, $msg);
+
+            my $disabled_class = RT::Class->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_class->Create(
+                Name => 'Disabled Class',
+            );
+            ok($ok, $msg);
+
+            my $enabled_role = RT::CustomRole->new(RT->SystemUser);
+            ($ok, $msg) = $enabled_role->Create(
+                Name => 'Enabled Role',
+            );
+            ok($ok, $msg);
+
+            my $disabled_role = RT::CustomRole->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_role->Create(
+                Name => 'Disabled Role',
+            );
+            ok($ok, $msg);
+
+            my $enabled_group = RT::Group->new(RT->SystemUser);
+            ($ok, $msg) = $enabled_group->CreateUserDefinedGroup(
+                Name => 'Enabled Group',
+            );
+            ok($ok, $msg);
+
+            my $disabled_group = RT::Group->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_group->CreateUserDefinedGroup(
+                Name => 'Disabled Group',
+            );
+            ok($ok, $msg);
+
+            my $enabled_user = RT::User->new(RT->SystemUser);
+            ($ok, $msg) = $enabled_user->Create(
+                Name => 'Enabled User',
+            );
+            ok($ok, $msg);
+
+            my $disabled_user = RT::User->new(RT->SystemUser);
+            ($ok, $msg) = $disabled_user->Create(
+                Name => 'Disabled User',
+            );
+            ok($ok, $msg);
+
+            for my $object ($enabled_cf, $disabled_cf,
+                            $enabled_scrip, $disabled_scrip,
+                            $enabled_class, $disabled_class,
+                            $enabled_role, $disabled_role) {
+
+                # slightly inconsistent API
+                my ($queue_a, $queue_b) = ($disabled_queue, $enabled_queue);
+                ($queue_a, $queue_b) = ($queue_a->Id, $queue_b->Id)
+                    if $object->isa('RT::Scrip')
+                    || $object->isa('RT::CustomRole');
+
+                ($ok, $msg) = $object->AddToObject($queue_a);
+                ok($ok, $msg);
+
+                ($ok, $msg) = $object->AddToObject($queue_b);
+                ok($ok, $msg);
+            }
+
+            for my $principal ($enabled_group, $disabled_group,
+                               $enabled_user, $disabled_user) {
+                ($ok, $msg) = $principal->PrincipalObj->GrantRight(Object => RT->System, Right => 'SeeQueue');
+                ok($ok, $msg);
+
+                for my $queue ($enabled_queue, $disabled_queue) {
+                    ($ok, $msg) = $principal->PrincipalObj->GrantRight(Object => $queue, Right => 'ShowTicket');
+                    ok($ok, $msg);
+
+                    ($ok, $msg) = $queue->AddWatcher(Type => 'AdminCc', PrincipalId => $principal->PrincipalId);
+                    ok($ok, $msg);
+                }
+            }
+
+            for my $cf ($enabled_cf, $disabled_cf) {
+                for my $queue ($enabled_queue, $disabled_queue) {
+                    ($ok, $msg) = $queue->AddCustomFieldValue(Field => $cf->Id, Value => $cf->Name);
+                    ok($ok, $msg);
+                }
+            }
+
+            for my $object ($disabled_queue, $disabled_cf,
+                            $disabled_scrip, $disabled_class,
+                            $disabled_role, $disabled_group,
+                            $disabled_user) {
+                ($ok, $msg) = $object->SetDisabled(1);
+                ok($ok, $msg);
+            }
+        },
+        present => sub {
+            my $from_initialdata = shift;
+
+            my $enabled_queue = RT::Queue->new(RT->SystemUser);
+            $enabled_queue->Load('Enabled Queue');
+            ok($enabled_queue->Id, 'loaded Enabled queue');
+            is($enabled_queue->Name, 'Enabled Queue', 'Enabled Queue Name');
+
+            my $disabled_queue = RT::Queue->new(RT->SystemUser);
+            $disabled_queue->Load('Disabled Queue');
+
+            my $enabled_cf = RT::CustomField->new(RT->SystemUser);
+            $enabled_cf->Load('Enabled CF');
+            ok($enabled_cf->Id, 'loaded Enabled CF');
+            is($enabled_cf->Name, 'Enabled CF', 'Enabled CF Name');
+            ok($enabled_cf->IsAdded($enabled_queue->Id), 'Enabled CF added to General');
+
+            is($enabled_queue->FirstCustomFieldValue('Enabled CF'), 'Enabled CF', 'OCFV');
+
+            my $disabled_cf = RT::CustomField->new(RT->SystemUser);
+            $disabled_cf->Load('Disabled CF');
+
+            my $enabled_scrip = RT::Scrip->new(RT->SystemUser);
+            $enabled_scrip->LoadByCols(Description => 'Enabled Scrip');
+            ok($enabled_scrip->Id, 'loaded Enabled Scrip');
+            is($enabled_scrip->Description, 'Enabled Scrip', 'Enabled Scrip Name');
+            ok($enabled_scrip->IsAdded($enabled_queue->Id), 'Enabled Scrip added to General');
+            my $disabled_scrip = RT::Scrip->new(RT->SystemUser);
+            $disabled_scrip->LoadByCols(Description => 'Disabled Scrip');
+
+            my $enabled_class = RT::Class->new(RT->SystemUser);
+            $enabled_class->Load('Enabled Class');
+            ok($enabled_class->Id, 'loaded Enabled Class');
+            is($enabled_class->Name, 'Enabled Class', 'Enabled Class Name');
+            ok($enabled_class->IsApplied($enabled_queue->Id), 'Enabled Class added to General');
+
+            my $disabled_class = RT::Class->new(RT->SystemUser);
+            $disabled_class->Load('Disabled Class');
+
+            my $enabled_role = RT::CustomRole->new(RT->SystemUser);
+            $enabled_role->Load('Enabled Role');
+            ok($enabled_role->Id, 'loaded Enabled Role');
+            is($enabled_role->Name, 'Enabled Role', 'Enabled Role Name');
+            ok($enabled_role->IsAdded($enabled_queue->Id), 'Enabled Role added to General');
+
+            my $disabled_role = RT::CustomRole->new(RT->SystemUser);
+            $disabled_role->Load('Disabled Role');
+
+            my $enabled_group = RT::Group->new(RT->SystemUser);
+            $enabled_group->LoadUserDefinedGroup('Enabled Group');
+            ok($enabled_group->Id, 'loaded Enabled Group');
+            is($enabled_group->Name, 'Enabled Group', 'Enabled Group Name');
+            ok($enabled_group->PrincipalObj->HasRight(Object => $enabled_queue, Right => 'ShowTicket'), 'Enabled Group has queue right');
+            ok($enabled_group->PrincipalObj->HasRight(Object => RT->System, Right => 'SeeQueue'), 'Enabled Group has global right');
+            ok($enabled_queue->AdminCc->HasMember($enabled_group->PrincipalObj), 'Enabled Group still queue watcher');
+
+            my $disabled_group = RT::Group->new(RT->SystemUser);
+            $disabled_group->LoadUserDefinedGroup('Disabled Group');
+
+            my $enabled_user = RT::User->new(RT->SystemUser);
+            $enabled_user->Load('Enabled User');
+            ok($enabled_user->Id, 'loaded Enabled User');
+            is($enabled_user->Name, 'Enabled User', 'Enabled User Name');
+            ok($enabled_user->PrincipalObj->HasRight(Object => $enabled_queue, Right => 'ShowTicket'), 'Enabled User has queue right');
+            ok($enabled_user->PrincipalObj->HasRight(Object => RT->System, Right => 'SeeQueue'), 'Enabled User has global right');
+            ok($enabled_queue->AdminCc->HasMember($enabled_user->PrincipalObj), 'Enabled User still queue watcher');
+
+            my $disabled_user = RT::User->new(RT->SystemUser);
+            $disabled_user->Load('Disabled User');
+
+            for my $object ($disabled_queue, $disabled_cf,
+                            $disabled_scrip, $disabled_class,
+                            $disabled_role, $disabled_group,
+                            $disabled_user) {
+                if ($from_initialdata) {
+                    ok(!$object->Id, "disabled " . ref($object) . " excluded");
+                }
+                else {
+                    ok($object->Disabled, "disabled " . ref($object));
+                }
+            }
+        },
+    },
+
+    {
+        name => 'Unapplied Objects',
+        create => sub {
+            my $scrip = RT::Scrip->new(RT->SystemUser);
+            my ($ok, $msg) = $scrip->Create(
+                Queue => 0,
+                Description => 'Unapplied Scrip',
+                Template => 'Blank',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $scrip->RemoveFromObject(0);
+            ok($ok, $msg);
+
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $cf->Create(
+                Name        => 'Unapplied CF',
+                Type        => 'FreeformSingle',
+                LookupType  => RT::Ticket->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+
+            my $class = RT::Class->new(RT->SystemUser);
+            ($ok, $msg) = $class->Create(
+                Name => 'Unapplied Class',
+            );
+            ok($ok, $msg);
+
+            my $role = RT::CustomRole->new(RT->SystemUser);
+            ($ok, $msg) = $role->Create(
+                Name => 'Unapplied Custom Role',
+            );
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $scrip = RT::Scrip->new(RT->SystemUser);
+            $scrip->LoadByCols(Description => 'Unapplied Scrip');
+            ok($scrip->Id, 'Unapplied scrip loaded');
+            is($scrip->Description, 'Unapplied Scrip');
+            ok(!$scrip->Disabled, 'not Disabled');
+            ok(!$scrip->IsGlobal, 'not Global');
+            ok(!$scrip->IsAdded($general->Id), 'not applied to General queue');
+
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            $cf->Load('Unapplied CF');
+            ok($cf->Id, 'Unapplied CF loaded');
+            is($cf->Name, 'Unapplied CF');
+            ok(!$cf->Disabled, 'not Disabled');
+            ok(!$cf->IsGlobal, 'not Global');
+            ok(!$cf->IsAdded($general->Id), 'not applied to General queue');
+
+            my $class = RT::Class->new(RT->SystemUser);
+            $class->Load('Unapplied Class');
+            ok($class->Id, 'Unapplied Class loaded');
+            is($class->Name, 'Unapplied Class');
+            ok(!$class->Disabled, 'not Disabled');
+            ok(!$class->IsApplied(0), 'not Global');
+            ok(!$class->IsApplied($general->Id), 'not applied to General queue');
+
+            my $role = RT::CustomRole->new(RT->SystemUser);
+            $role->Load('Unapplied Custom Role');
+            ok($role->Id, 'Unapplied Custom Role loaded');
+            is($role->Name, 'Unapplied Custom Role');
+            ok(!$role->Disabled, 'not Disabled');
+            ok(!$role->IsAdded(0), 'not Global');
+            ok(!$role->IsAdded($general->Id), 'not applied to General queue');
+        },
+    },
+
+    {
+        name => 'Global Objects',
+        create => sub {
+            my $scrip = RT::Scrip->new(RT->SystemUser);
+            my ($ok, $msg) = $scrip->Create(
+                Queue => 0,
+                Description => 'Global Scrip',
+                Template => 'Blank',
+                ScripCondition => 'On Create',
+                ScripAction => 'Notify Owner',
+            );
+            ok($ok, $msg);
+
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $cf->Create(
+                Name        => 'Global CF',
+                Type        => 'FreeformSingle',
+                LookupType  => RT::Ticket->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $cf->AddToObject(RT::Queue->new(RT->SystemUser));
+            ok($ok, $msg);
+
+            my $class = RT::Class->new(RT->SystemUser);
+            ($ok, $msg) = $class->Create(
+                Name => 'Global Class',
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $class->AddToObject(RT::Queue->new(RT->SystemUser));
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $scrip = RT::Scrip->new(RT->SystemUser);
+            $scrip->LoadByCols(Description => 'Global Scrip');
+            ok($scrip->Id, 'Global scrip loaded');
+            is($scrip->Description, 'Global Scrip');
+            ok(!$scrip->Disabled, 'not Disabled');
+            ok($scrip->IsGlobal, 'Global');
+            ok(!$scrip->IsAdded($general->Id), 'not applied to General queue');
+
+            my $cf = RT::CustomField->new(RT->SystemUser);
+            $cf->Load('Global CF');
+            ok($cf->Id, 'Global CF loaded');
+            is($cf->Name, 'Global CF');
+            ok(!$cf->Disabled, 'not Disabled');
+            ok($cf->IsGlobal, 'Global');
+            ok(!$cf->IsAdded($general->Id), 'not applied to General queue');
+
+            my $class = RT::Class->new(RT->SystemUser);
+            $class->Load('Global Class');
+            ok($class->Id, 'Global Class loaded');
+            is($class->Name, 'Global Class');
+            ok(!$class->Disabled, 'not Disabled');
+            ok($class->IsApplied(0), 'Global');
+            ok(!$class->IsApplied($general->Id), 'not applied to General queue');
+        },
+    },
+    {
+        name => 'Templates',
+        create => sub {
+            my $global = RT::Template->new(RT->SystemUser);
+            my ($ok, $msg) = $global->Create(
+                Name => 'Initialdata test',
+                Queue => 0,
+                Description => 'foo',
+                Content => "Hello こんにちは",
+                Type => "Simple",
+            );
+            ok($ok, $msg);
+
+            my $queue = RT::Template->new(RT->SystemUser);
+            ($ok, $msg) = $queue->Create(
+                Name => 'Initialdata test',
+                Queue => $general->Id,
+                Description => 'override for Swedes',
+                Content => "Hello Hallå",
+                Type => "Simple",
+            );
+            ok($ok, $msg);
+
+            my $standalone = RT::Template->new(RT->SystemUser);
+            ($ok, $msg) = $standalone->Create(
+                Name => 'Standalone test',
+                Queue => $general->Id,
+                Description => 'no global version',
+                Content => "this was broken!",
+                Type => "Perl",
+            );
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $global = RT::Template->new(RT->SystemUser);
+            $global->LoadGlobalTemplate('Initialdata test');
+            ok($global->Id, 'loaded template');
+            is($global->Name, 'Initialdata test', 'Name');
+            is($global->Queue, 0, 'Queue');
+            is($global->Description, 'foo', 'Description');
+            is($global->Content, 'Hello こんにちは', 'Content');
+            is($global->Type, 'Simple', 'Type');
+
+            my $queue = RT::Template->new(RT->SystemUser);
+            $queue->LoadQueueTemplate(Name => 'Initialdata test', Queue => $general->Id);
+            ok($queue->Id, 'loaded template');
+            is($queue->Name, 'Initialdata test', 'Name');
+            is($queue->Queue, $general->Id, 'Queue');
+            is($queue->Description, 'override for Swedes', 'Description');
+            is($queue->Content, 'Hello Hallå', 'Content');
+            is($queue->Type, 'Simple', 'Type');
+
+            my $standalone = RT::Template->new(RT->SystemUser);
+            $standalone->LoadQueueTemplate(Name => 'Standalone test', Queue => $general->Id);
+            ok($standalone->Id, 'loaded template');
+            is($standalone->Name, 'Standalone test', 'Name');
+            is($standalone->Queue, $general->Id, 'Queue');
+            is($standalone->Description, 'no global version', 'Description');
+            is($standalone->Content, 'this was broken!', 'Content');
+            is($standalone->Type, 'Perl', 'Type');
+        },
+    },
+    {
+        name => 'Articles',
+        create => sub {
+            my $class = RT::Class->new(RT->SystemUser);
+            my ($ok, $msg) = $class->Create(
+                Name => 'Test',
+            );
+            ok($ok, $msg);
+
+            my $content = RT::CustomField->new(RT->SystemUser);
+            $content->LoadByCols(
+                Name => "Content",
+                Type => "Text",
+                LookupType => RT::Article->CustomFieldLookupType,
+            );
+            ok($content->Id, "loaded builtin Content CF");
+
+            my $tags = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $tags->Create(
+                Name => "Tags",
+                Type => "FreeformMultiple",
+                LookupType => RT::Article->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $tags->AddToObject($class);
+            ok($ok, $msg);
+
+            my $clearance = RT::CustomField->new(RT->SystemUser);
+            ($ok, $msg) = $clearance->Create(
+                Name => "Clearance",
+                Type => "SelectSingle",
+                LookupType => RT::Article->CustomFieldLookupType,
+            );
+            ok($ok, $msg);
+            ($ok, $msg) = $clearance->AddToObject($class);
+            ok($ok, $msg);
+
+            ($ok, $msg) = $clearance->AddValue(Name => 'Unclassified');
+            ok($ok, $msg);
+            ($ok, $msg) = $clearance->AddValue(Name => 'Classified');
+            ok($ok, $msg);
+            ($ok, $msg) = $clearance->AddValue(Name => 'Top Secret');
+            ok($ok, $msg);
+
+            my $coffee = RT::Article->new(RT->SystemUser);
+            ($ok, $msg) = $coffee->Create(
+                Class => 'Test',
+                Name  => 'Coffee time',
+                "CustomField-" . $content->Id => 'Always',
+                "CustomField-" . $clearance->Id => 'Unclassified',
+                "CustomField-" . $tags->Id => ['drink', 'coffee', 'how the humans live'],
+            );
+            ok($ok, $msg);
+
+            my $twd = RT::Article->new(RT->SystemUser);
+            ($ok, $msg) = $twd->Create(
+                Class => 'Test',
+                Name  => 'Total world domination plans',
+                "CustomField-" . $content->Id => 'REDACTED',
+                "CustomField-" . $clearance->Id => 'Top Secret',
+                "CustomField-" . $tags->Id => ['snakes', 'clowns'],
+            );
+            ok($ok, $msg);
+        },
+        present => sub {
+            my $class = RT::Class->new(RT->SystemUser);
+            $class->Load('Test');
+            ok($class->Id, 'loaded class');
+            is($class->Name, 'Test', 'Name');
+
+            my $coffee = RT::Article->new(RT->SystemUser);
+            $coffee->LoadByCols(Name => 'Coffee time');
+            ok($coffee->Id, 'loaded article');
+            is($coffee->Name, 'Coffee time', 'Name');
+            is($coffee->Class, $class->Id, 'Class');
+            is($coffee->FirstCustomFieldValue('Content'), 'Always', 'Content CF');
+            is($coffee->FirstCustomFieldValue('Clearance'), 'Unclassified', 'Clearance CF');
+            is($coffee->CustomFieldValuesAsString('Tags', Separator => '.'), 'drink.coffee.how the humans live', 'Tags CF');
+
+            my $twd = RT::Article->new(RT->SystemUser);
+            $twd->LoadByCols(Name => 'Total world domination plans');
+            ok($twd->Id, 'loaded article');
+            is($twd->Name, 'Total world domination plans', 'Name');
+            is($twd->Class, $class->Id, 'Class');
+            is($twd->FirstCustomFieldValue('Content'), 'REDACTED', 'Content CF');
+            is($twd->FirstCustomFieldValue('Clearance'), 'Top Secret', 'Clearance CF');
+            is($twd->CustomFieldValuesAsString('Tags', Separator => '.'), 'snakes.clowns', 'Tags CF');
+        },
+    },
+);
+
+my $id = 0;
+for my $test (@tests) {
+    $id++;
+    my $directory = File::Spec->catdir(RT::Test->temp_directory, "export-$id");
+
+    # we get a lot of warnings about already-existing objects; suppress them
+    # for now until we clean it up
+    my $warn = $SIG{__WARN__};
+    local $SIG{__WARN__} = sub {
+        return if $_[0] =~ join '|', (
+            qr/^Name in use$/,
+            qr/^A Template with that name already exists$/,
+            qr/^.* already has the right .* on .*$/,
+            qr/^Invalid value for Name$/,
+            qr/^Queue already exists$/,
+            qr/^Invalid Name \(names must be unique and may not be all digits\)$/,
+        );
+
+        # Avoid reporting this anonymous call frame as the source of the warning
+        goto &$warn;
+    };
+
+    my $name        = delete $test->{name};
+    my $create      = delete $test->{create};
+    my $absent      = delete $test->{absent};
+    my $present     = delete $test->{present};
+    my $raw         = delete $test->{raw};
+    my $export_args = delete $test->{export_args};
+    fail("Unexpected keys for test #$id ($name): " . join(', ', sort keys %$test)) if keys %$test;
+
+    subtest "$name (ordinary creation)" => sub {
+        autorollback(sub {
+            $absent->(0) if $absent;
+            $create->();
+            $present->(0) if $present;
+            export_initialdata($directory, %{ $export_args || {} });
+        });
+    };
+
+    if ($raw) {
+        subtest "$name (testing initialdata)" => sub {
+            my $file = File::Spec->catfile($directory, "initialdata.json");
+            my $content = slurp($file);
+            my $json = JSON->new->decode($content);
+            $raw->($json, $content);
+        };
+    }
+
+    subtest "$name (from export-$id/initialdata.json)" => sub {
+        autorollback(sub {
+            $absent->(1) if $absent;
+            import_initialdata($directory);
+            $present->(1) if $present;
+        });
+    };
+}
+
+done_testing();
+
+sub autorollback {
+    my $code = shift;
+
+    $RT::Handle->BeginTransaction;
+    {
+        # avoid "Rollback and commit are mixed while escaping nested transaction" warnings
+        # due to (begin; (begin; commit); rollback)
+        no warnings 'redefine';
+        local *DBIx::SearchBuilder::Handle::BeginTransaction = sub {};
+        local *DBIx::SearchBuilder::Handle::Commit = sub {};
+        local *DBIx::SearchBuilder::Handle::Rollback = sub {};
+
+        $code->();
+    }
+    $RT::Handle->Rollback;
+}
+
+sub export_initialdata {
+    my $directory = shift;
+    my %args      = @_;
+    local @RT::Record::ISA = qw( DBIx::SearchBuilder::Record RT::Base );
+
+    use RT::Migrate::Serializer::JSON;
+    my $migrator = RT::Migrate::Serializer::JSON->new(
+        Directory          => $directory,
+        Verbose            => 0,
+        AllUsers           => 0,
+        FollowACL          => 1,
+        FollowScrips       => 1,
+        FollowTransactions => 0,
+        FollowTickets      => 0,
+        FollowAssets       => 0,
+        FollowDisabled     => 0,
+        %args,
+    );
+
+    $migrator->Export;
+}
+
+sub import_initialdata {
+    my $directory = shift;
+    my $initialdata = File::Spec->catfile($directory, "initialdata.json");
+
+    ok(-e $initialdata, "File $initialdata exists");
+
+    my ($rv, $msg) = RT->DatabaseHandle->InsertData( $initialdata, undef, disconnect_after => 0 );
+    ok($rv, "Inserted test data from $initialdata")
+        or diag "Error: $msg";
+}
+
+sub slurp {
+    my $file = shift;
+    local $/;
+    open (my $f, '<:encoding(UTF-8)', $file)
+        or die "Cannot open initialdata file '$file' for read: $@";
+    return scalar <$f>;
+}

commit e63b7e43856a1cdf58ce7819ed42c59dddf46539
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Aug 7 03:22:40 2018 +0800

    Skip tests if required RT::Extension::Initialdata::JSON is not available

diff --git a/t/api/initialdata-roundtrip.t b/t/api/initialdata-roundtrip.t
index 8098284af..14f2995ff 100644
--- a/t/api/initialdata-roundtrip.t
+++ b/t/api/initialdata-roundtrip.t
@@ -3,10 +3,22 @@ use strict;
 use warnings;
 use JSON;
 
-use RT::Test tests => undef, config => << 'CONFIG';
+require RT::Test;
+use Test::More;
+
+eval {
+    RT::Test->import(
+        tests => undef,
+        config => << 'CONFIG',
 Plugin('RT::Extension::Initialdata::JSON');
 Set($InitialdataFormatHandlers, [ 'perl', 'RT::Extension::Initialdata::JSON' ]);
 CONFIG
+    );
+};
+
+if ( $@ ) {
+    plan skip_all => 'Unable to test without RT::Extension::Initialdata::JSON';
+}
 
 my $general = RT::Queue->new(RT->SystemUser);
 $general->Load('General');
@@ -1060,7 +1072,7 @@ for my $test (@tests) {
     };
 }
 
-done_testing();
+RT::Test::done_testing();
 
 sub autorollback {
     my $code = shift;

-----------------------------------------------------------------------


More information about the rt-commit mailing list