tfs_graph 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +19 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +119 -0
  8. data/Rakefile +1 -0
  9. data/lib/tfs_graph/associators/branch_associator.rb +25 -0
  10. data/lib/tfs_graph/associators/changeset_tree_creator.rb +19 -0
  11. data/lib/tfs_graph/branch/branch_archive_handler.rb +23 -0
  12. data/lib/tfs_graph/branch/branch_normalizer.rb +11 -0
  13. data/lib/tfs_graph/branch/branch_store.rb +53 -0
  14. data/lib/tfs_graph/branch.rb +157 -0
  15. data/lib/tfs_graph/changeset/changeset_normalizer.rb +21 -0
  16. data/lib/tfs_graph/changeset/changeset_store.rb +65 -0
  17. data/lib/tfs_graph/changeset.rb +71 -0
  18. data/lib/tfs_graph/changeset_merge/changeset_merge_normalizer.rb +21 -0
  19. data/lib/tfs_graph/changeset_merge/changeset_merge_store.rb +24 -0
  20. data/lib/tfs_graph/changeset_merge.rb +57 -0
  21. data/lib/tfs_graph/config.rb +10 -0
  22. data/lib/tfs_graph/entity.rb +16 -0
  23. data/lib/tfs_graph/graph_populator.rb +35 -0
  24. data/lib/tfs_graph/normalizer.rb +30 -0
  25. data/lib/tfs_graph/populators/everything.rb +20 -0
  26. data/lib/tfs_graph/populators/for_project.rb +18 -0
  27. data/lib/tfs_graph/populators/since_date.rb +26 -0
  28. data/lib/tfs_graph/populators/since_last.rb +26 -0
  29. data/lib/tfs_graph/populators/utilities.rb +38 -0
  30. data/lib/tfs_graph/populators.rb +11 -0
  31. data/lib/tfs_graph/project/project_normalizer.rb +12 -0
  32. data/lib/tfs_graph/project/project_store.rb +30 -0
  33. data/lib/tfs_graph/project.rb +59 -0
  34. data/lib/tfs_graph/store_helpers.rb +31 -0
  35. data/lib/tfs_graph/tfs_client.rb +37 -0
  36. data/lib/tfs_graph/tfs_helpers.rb +42 -0
  37. data/lib/tfs_graph/version.rb +3 -0
  38. data/lib/tfs_graph.rb +19 -0
  39. data/spec/factories.rb +20 -0
  40. data/spec/helpers_spec.rb +48 -0
  41. data/spec/spec_helper.rb +43 -0
  42. data/tfs_graph.gemspec +26 -0
  43. metadata +144 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 305a88a142479775b0507af876a4e432b5069c62
4
+ data.tar.gz: 82b14fc50dd7d1b0fbb76bde73d861c4e6e6fef2
5
+ SHA512:
6
+ metadata.gz: 383e2e25094ffc75ef1ec0a8b42fb2c8d3cc5c34198a6149ed9a633d2249f0bfdac5ae36040d70690fbe677afe2d95a8ef3950093b32c6b5b0cf4fa249bf8c65
7
+ data.tar.gz: 08b56fcb8b084152b282362434a4e692e19e967b5f626e767426b0cf1296d9b93431ba0fc496f1b02b9e7c55988cb6b23e67cf44360a5f160d4dd46ea875d953
data/.gitignore ADDED
@@ -0,0 +1,20 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ spec/fixtures
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+
20
+ bfg.rb
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ tfs_graph
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.0.0-p247
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in tfs_graph.gemspec
4
+ gemspec
5
+
6
+ gem "related", git: "https://github.com/plukevdh/related.git", branch: "preserve_external_ids"
7
+
8
+ group :test do
9
+ gem 'rspec'
10
+ gem 'rspec-given'
11
+ gem 'webmock'
12
+ gem 'vcr'
13
+ gem 'factory_girl'
14
+ end
15
+
16
+ group :test, :development do
17
+ gem "pry"
18
+ gem 'benchmark-ips'
19
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Luke van der Hoeven
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # TFSGraph
2
+
3
+ This is a nice little library to tie together TFS data (using the TFS OData API via ruby_tfs) Related, a super-lightweight faux-graph DB implementation built on top of Redis.
4
+
5
+ To initially map your TFS data, you can run the GraphPopulator.
6
+
7
+ ```ruby
8
+ include TFSGraph
9
+
10
+ GraphPopulator.populate_all
11
+ ```
12
+
13
+ This will scrape and dump all of the TFS data into Redis and build the initial set of relationships. From there, you can traverse the data starting from the projects and digging in deeper. The *Store (ProjectStore/BranchStore) classes can help make the initial queries against this.
14
+
15
+ ```ruby
16
+ # Get all the branch objects
17
+ projects = ProjectStore.all_cached
18
+
19
+ # Get all the branches for a project
20
+ branches = projects.branches
21
+
22
+ # Gett all the changesets from the branches
23
+ changesets = branches.map do |branch|
24
+ branch.changesets
25
+ end
26
+
27
+ # Get all the changesets that have merged with this changeset
28
+ changesets.first.merges
29
+
30
+ # Or get all the changesets that this one has merged into
31
+ changesets.first.merged
32
+
33
+
34
+ # Get all the "master" branches
35
+ projects.roots
36
+ ```
37
+
38
+ There are plenty more relationships to traverse an each object type (Project/Branch/Changeset) has its own set of properties.
39
+
40
+ - Project
41
+ - name
42
+
43
+ - Branch
44
+ - original_path
45
+ - path
46
+ - project
47
+ - name
48
+ - root
49
+ - created
50
+ - type
51
+ - archived
52
+
53
+ - Changeset
54
+ - comment
55
+ - committer
56
+ - created
57
+ - id
58
+ - branch_path
59
+ - tags
60
+ - parent
61
+ - merge_parent
62
+
63
+ Changesets are also enumerable. So you can do things like
64
+
65
+ ```ruby
66
+ child = changeset.next
67
+ child.next # keep `next`ing until StopIteration is raised
68
+
69
+
70
+ # loops also work with this:
71
+
72
+ loop do
73
+ child = changeset.next
74
+ # do somethign with child
75
+
76
+ changeset = child
77
+ end
78
+ ```
79
+
80
+ This is not a subclass of the enumerator class, so it won't behave the same as other enumerable classes.
81
+
82
+
83
+ ## Requirements
84
+
85
+ You will need Redis installed. You can configure like so:
86
+
87
+ ```ruby
88
+ TFSGraph.config do |c|
89
+ c.tfs = {
90
+ username: "me",
91
+ password: "lame",
92
+ endpoint: "https://my-odata-endpoint/Collection"
93
+ },
94
+ c.redis = "localhost:6379/Namespace"
95
+ end
96
+ ```
97
+
98
+ ## Installation
99
+
100
+ Add this line to your application's Gemfile:
101
+
102
+ gem 'tfs_graph'
103
+
104
+ And then execute:
105
+
106
+ $ bundle
107
+
108
+ Or install it yourself as:
109
+
110
+ $ gem install tfs_graph
111
+
112
+
113
+ ## Contributing
114
+
115
+ 1. Fork it
116
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
117
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
118
+ 4. Push to the branch (`git push origin my-new-feature`)
119
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,25 @@
1
+ module TFSGraph
2
+ class BranchAssociator
3
+ class << self
4
+
5
+ # sets up parent/child relationships
6
+ def associate_groups(sets_by_branch)
7
+ sets_by_branch.each do |group|
8
+ associate(group)
9
+ end
10
+ end
11
+
12
+ def associate(changesets)
13
+ return if changesets.empty?
14
+
15
+ change = changesets.first
16
+ root = change.merges.max
17
+
18
+ return if root.nil?
19
+
20
+ change.parent = root.id
21
+ change.save
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ module TFSGraph
2
+ class ChangesetTreeCreator
3
+ class << self
4
+ def to_tree(branch)
5
+ changesets = branch.changesets
6
+ changesets.each.with_index do |changeset, i|
7
+ parent = (i == 0) ? branch : changesets[i-1]
8
+
9
+ if Changeset.find parent.id
10
+ changeset.parent = parent.id
11
+ changeset.save
12
+ end
13
+
14
+ Related::Relationship.create :child, parent, changeset
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ module TFSGraph
2
+ class BranchArchiveHandler
3
+ # Archives == moving a branch from root to an archive folder
4
+ # The actual history is attached to the old branch path in TFS,
5
+ # but now we have a "ghost" branch (with the important history attached)
6
+ # and an "archived" branch (by rename in TFS). Since the "archived" branch
7
+ # is fairly useless, we'll hide it in favor of the "ghost" branch.
8
+ class << self
9
+ def hide_all_archives
10
+ ProjectStore.all_cached.map {|project| hide_moved_archives_for_project(project) }
11
+ end
12
+
13
+ def hide_moved_archives_for_project(project)
14
+ archived = project.branches.group_by(&:path).select {|_, group| group.size > 1 }
15
+ archived.each do |path, group|
16
+ group.select(&:archived?).each(&:hide!)
17
+ group.reject(&:archived?).each(&:archive!)
18
+ end
19
+ archived
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ require 'tfs_graph/normalizer'
2
+
3
+ module TFSGraph
4
+ class BranchNormalizer < Normalizer
5
+ class << self
6
+ def schema
7
+ Branch::SCHEMA
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,53 @@
1
+ require 'tfs_graph/tfs_client'
2
+ require 'tfs_graph/tfs_helpers'
3
+ require 'tfs_graph/store_helpers'
4
+
5
+ require 'tfs_graph/branch/branch_normalizer'
6
+ require 'tfs_graph/branch'
7
+
8
+ module TFSGraph
9
+ class BranchStore
10
+ include TFSClient
11
+ include TFSHelpers
12
+ include StoreHelpers
13
+
14
+ LIMIT = 1000
15
+
16
+ def initialize(project)
17
+ @project = project
18
+ end
19
+
20
+ def cache_all
21
+ persist(all)
22
+ end
23
+
24
+ def cache_since_last_update
25
+ persist since_last_update
26
+ end
27
+
28
+ def all
29
+ normalize root_query.run
30
+ end
31
+
32
+ def since_last_update
33
+ normalize root_query.where("DateCreated gt DateTime'#{last_updated_on.iso8601}'").run
34
+ end
35
+
36
+ private
37
+ def root_query
38
+ tfs.projects(@project.name).branches.limit(LIMIT)
39
+ end
40
+
41
+ def normalize(branches)
42
+ BranchNormalizer.normalize_many branches
43
+ end
44
+
45
+ def persist(branches)
46
+ branches.map do |branch_attrs|
47
+ branch = Branch.create branch_attrs
48
+ Related::Relationship.create(:branches, @project, branch)
49
+ branch
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,157 @@
1
+ require 'tfs_graph/entity'
2
+ require 'tfs_graph/tfs_helpers'
3
+
4
+ module TFSGraph
5
+ class Branch < Entity
6
+ extend TFSHelpers
7
+ extend Comparable
8
+
9
+ SCHEMA = {
10
+ original_path: {key: "Path", type: String},
11
+ path: {key: "Path", converter: ->(path) { repath_archive(path) }, type: String},
12
+ project: {converter: ->(path) { branch_project(path) }, key: "Path", type: String},
13
+ name: {converter: ->(path) { branch_path_to_name(path) }, key: "Path", type: String},
14
+ root: {converter: ->(path) { repath_archive(server_path_to_odata_path(path)) if path }, key: "ParentBranch", type: String},
15
+ created: {key: "DateCreated", type: DateTime},
16
+ type: {default: "Feature", type: Integer},
17
+ archived: {default: false, type: String},
18
+ hidden: {default: false, type: String}
19
+ }
20
+
21
+ BRANCH_TYPES = [
22
+ :master,
23
+ :release,
24
+ :feature
25
+ ]
26
+
27
+ ARCHIVED_FLAGS = ["Archive"]
28
+ RELEASE_MATCHER = /^(.+)-r(\d+)-(\d+)$/i
29
+
30
+ act_as_entity
31
+
32
+ before_create :detect_type, :detect_archived
33
+
34
+ BRANCH_TYPES.each do |t|
35
+ define_method "#{t}?".to_sym do
36
+ BRANCH_TYPES.at(type) == t
37
+ end
38
+ end
39
+
40
+ def archived?
41
+ archived.to_s == "true"
42
+ end
43
+
44
+ def hidden?
45
+ hidden.to_s == "true"
46
+ end
47
+
48
+ def named_type
49
+ BRANCH_TYPES[type]
50
+ end
51
+
52
+ def hide!
53
+ self.hidden = true
54
+ save
55
+ end
56
+
57
+ def archive!
58
+ self.archived = true
59
+ save
60
+ end
61
+
62
+ def rootless?
63
+ !master? && root.empty?
64
+ end
65
+
66
+ def type_index(name)
67
+ BRANCH_TYPES.index(name.to_sym)
68
+ end
69
+
70
+ # returns a branch
71
+ def absolute_root
72
+ @absolute_root ||= begin
73
+ item = self
74
+ proj = ProjectStore.find_cached project
75
+
76
+ until(item.master?) do
77
+ item = proj.branches.detect {|branch| branch.path == item.root }
78
+ end
79
+
80
+ item
81
+ end
82
+ end
83
+
84
+ def branch?
85
+ !master?
86
+ end
87
+
88
+ # branches this one touches or is touched
89
+ def related_branches
90
+ incoming(:related).options(model: Branch).nodes.to_a.map &:id
91
+ end
92
+
93
+ def changesets
94
+ outgoing(:changesets).options(model: Changeset).nodes.to_a
95
+ end
96
+
97
+ def contributors
98
+ changesets.group_by(&:committer)
99
+ end
100
+
101
+ def root_changeset
102
+ @root ||= outgoing(:child).options(model: Changeset).nodes.to_a.first
103
+ end
104
+
105
+ def last_changeset
106
+ changesets.last
107
+ end
108
+
109
+ def ahead_of_master
110
+ return 0 unless absolute_root
111
+ self.outgoing(:changesets)
112
+ .diff(absolute_root.outgoing(:included)
113
+ .intersect(self.outgoing(:changesets)))
114
+ .to_a.count
115
+ end
116
+
117
+ # gets the set of changesets that exist in both root and self
118
+ # then gets a diff of that set and the root.
119
+ def behind_master
120
+ return 0 unless absolute_root
121
+ absolute_root.outgoing(:changesets)
122
+ .diff(self.outgoing(:included)
123
+ .intersect(absolute_root.outgoing(:changesets)))
124
+ .to_a.count
125
+ end
126
+
127
+ def <=>(other)
128
+ path <=> other.path
129
+ end
130
+
131
+ def as_json(options={})
132
+ options.merge! methods: :related_branches
133
+ super
134
+ end
135
+
136
+ private
137
+ def detect_type
138
+ return self.type = type_index(:master) if (root.empty?)
139
+ return self.type = type_index(:release) if !(name =~ RELEASE_MATCHER).nil?
140
+ self.type = type_index(:feature)
141
+ nil
142
+ end
143
+
144
+ def detect_archived
145
+ self.archived = ARCHIVED_FLAGS.any? {|flag| original_path.include? flag }
146
+ nil
147
+ end
148
+
149
+ def self.repath_archive(path)
150
+ path = path.dup
151
+ return path unless ARCHIVED_FLAGS.any? {|flag| path.include? flag }
152
+
153
+ ARCHIVED_FLAGS.each {|flag| path.gsub!(/#{flag}>?(?:.*)>/, "") }
154
+ path
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,21 @@
1
+ require 'tfs_graph/normalizer'
2
+
3
+ module TFSGraph
4
+ class ChangesetNormalizer < Normalizer
5
+ class << self
6
+ def normalize_many(data, branch)
7
+ changesets = data.map {|item| normalize(item, branch) }
8
+ end
9
+
10
+ def normalize(item, branch)
11
+ item = super(item)
12
+ item[:branch_path] = branch
13
+ item
14
+ end
15
+
16
+ def schema
17
+ Changeset::SCHEMA
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,65 @@
1
+ require 'tfs_graph/tfs_client'
2
+ require 'tfs_graph/store_helpers'
3
+
4
+ require 'tfs_graph/changeset/changeset_normalizer'
5
+ require 'tfs_graph/changeset'
6
+ # Wraps domain knowledge of changeset TFS access
7
+
8
+ module TFSGraph
9
+ class ChangesetStore
10
+ include TFSClient
11
+ include StoreHelpers
12
+
13
+ LIMIT = 10000
14
+
15
+ def initialize(branch)
16
+ @branch = branch
17
+ end
18
+
19
+ def cache_all
20
+ persist all
21
+ end
22
+
23
+ def cache_since_last_update
24
+ persist since_last_update
25
+ end
26
+
27
+ def cache_since_date(start)
28
+ persist since_date(start)
29
+ end
30
+
31
+ def all
32
+ normalize root_query.run
33
+ end
34
+
35
+ def since_date(date)
36
+ normalize root_query.where("CreationDate gt DateTime'#{date}'").run
37
+ end
38
+
39
+ def since_last_update
40
+ since_date(last_updated_on.iso8601)
41
+ end
42
+
43
+ private
44
+ def root_query
45
+ tfs.branches(@branch.path).changesets.limit(LIMIT)
46
+ end
47
+
48
+ def normalize(changesets)
49
+ ChangesetNormalizer.normalize_many changesets, @branch.path
50
+ end
51
+
52
+ def persist(changesets)
53
+ changesets.map do |attrs|
54
+ begin
55
+ changeset = Changeset.create attrs
56
+ Related::Relationship.create :changesets, @branch, changeset
57
+ changeset
58
+ rescue Related::ValidationsFailed => ex
59
+ # puts ex.message
60
+ next
61
+ end
62
+ end.compact
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,71 @@
1
+ require 'tfs_graph/entity'
2
+ require 'tfs_graph/tfs_helpers'
3
+
4
+ # FIXME: DRY along side the Branch class
5
+ module TFSGraph
6
+ class Changeset < Entity
7
+ extend TFSHelpers
8
+ extend Comparable
9
+
10
+ SCHEMA = {
11
+ comment: {key: "Comment", type: String},
12
+ committer: {key: "Committer", converter: ->(name) { base_username(name) }, type: String},
13
+ created: {key: "CreationDate", type: DateTime},
14
+ id: {key: "Id", type: Integer},
15
+ branch_path: {type: String, default: nil},
16
+ tags: {type: Array, default: []},
17
+ parent: {type: Integer, default: nil},
18
+ merge_parent: {type: Integer, default: nil}
19
+ }
20
+
21
+ act_as_entity
22
+
23
+ def <=>(other)
24
+ id <=> other.id
25
+ end
26
+
27
+ def next
28
+ child = outgoing(:child).options(model: self.class).nodes.to_a.first
29
+ raise StopIteration unless child
30
+ child
31
+ end
32
+
33
+ def branch
34
+ incoming(:changesets).options(model: Branch).nodes.to_a.first
35
+ end
36
+
37
+ def formatted_created
38
+ created.strftime("%m/%d/%Y")
39
+ end
40
+
41
+ %w(merges merged).each do |type|
42
+ define_method type do
43
+ get_merges_for outgoing(type.to_sym)
44
+ end
45
+
46
+ define_method "#{type}_ids" do
47
+ send(type).map &:id
48
+ end
49
+ end
50
+
51
+ def as_json(options={})
52
+ options.merge! methods: [:merges_ids, :merged_ids]
53
+ super
54
+ end
55
+
56
+ def set_merging_to
57
+ into = merged.max
58
+ self.merge_parent = into.id if into
59
+ end
60
+
61
+ def set_merging_from
62
+ from = merges.max
63
+ self.merge_parent = from.id if from
64
+ end
65
+
66
+ private
67
+ def get_merges_for(merge)
68
+ merge.options(model: self.class).nodes.to_a
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,21 @@
1
+ require 'tfs_graph/normalizer'
2
+
3
+ module TFSGraph
4
+ class ChangesetMergeNormalizer < Normalizer
5
+ class << self
6
+ def normalize_many(data, branch)
7
+ changesets = data.map {|item| normalize(item, branch) }
8
+ end
9
+
10
+ def normalize(item, branch)
11
+ item = super(item)
12
+ item[:branch] = branch.name
13
+ item
14
+ end
15
+
16
+ def schema
17
+ ChangesetMerge::SCHEMA
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ require 'tfs_graph/tfs_client'
2
+ require 'tfs_graph/changeset_merge/changeset_merge_normalizer'
3
+ require 'tfs_graph/changeset_merge'
4
+
5
+ module TFSGraph
6
+ class ChangesetMergeStore
7
+ include TFSClient
8
+
9
+ LIMIT = 10000
10
+
11
+ def initialize(branch)
12
+ @branch = branch
13
+ end
14
+
15
+ def cache
16
+ merges = tfs.branches(@branch.path).changesetmerges.limit(LIMIT).run
17
+ normalized = ChangesetMergeNormalizer.normalize_many merges, @branch
18
+
19
+ normalized.map do |attrs|
20
+ ChangesetMerge.create(attrs)
21
+ end.compact
22
+ end
23
+ end
24
+ end