modulesync 1.0.0 → 2.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +9 -3
- data/.rubocop.yml +3 -0
- data/.rubocop_todo.yml +1 -1
- data/.travis.yml +11 -7
- data/CHANGELOG.md +57 -0
- data/Gemfile +5 -1
- data/HISTORY.md +227 -0
- data/README.md +58 -16
- data/Rakefile +25 -0
- data/features/update.feature +55 -0
- data/lib/modulesync.rb +83 -48
- data/lib/modulesync/cli.rb +9 -3
- data/lib/modulesync/pr/github.rb +57 -0
- data/lib/modulesync/pr/gitlab.rb +54 -0
- data/lib/modulesync/renderer.rb +4 -3
- data/lib/modulesync/util.rb +4 -1
- data/modulesync.gemspec +5 -4
- data/spec/unit/modulesync/pr/github_spec.rb +49 -0
- data/spec/unit/modulesync/pr/gitlab_spec.rb +81 -0
- data/spec/unit/modulesync_spec.rb +8 -0
- metadata +37 -10
data/Rakefile
CHANGED
@@ -19,3 +19,28 @@ Cucumber::Rake::Task.new do |t|
|
|
19
19
|
end
|
20
20
|
|
21
21
|
task :test => %i[clean spec cucumber rubocop]
|
22
|
+
task :default => %i[test]
|
23
|
+
|
24
|
+
begin
|
25
|
+
require 'github_changelog_generator/task'
|
26
|
+
GitHubChangelogGenerator::RakeTask.new :changelog do |config|
|
27
|
+
config.header = "# Changelog\n\nAll notable changes to this project will be documented in this file."
|
28
|
+
config.exclude_labels = %w[duplicate question invalid wontfix wont-fix modulesync skip-changelog]
|
29
|
+
config.user = 'voxpupuli'
|
30
|
+
config.project = 'modulesync'
|
31
|
+
config.future_release = Gem::Specification.load("#{config.project}.gemspec").version
|
32
|
+
end
|
33
|
+
|
34
|
+
# Workaround for https://github.com/github-changelog-generator/github-changelog-generator/issues/715
|
35
|
+
require 'rbconfig'
|
36
|
+
if RbConfig::CONFIG['host_os'] =~ /linux/
|
37
|
+
task :changelog do
|
38
|
+
puts 'Fixing line endings...'
|
39
|
+
changelog_file = File.join(__dir__, 'CHANGELOG.md')
|
40
|
+
changelog_txt = File.read(changelog_file)
|
41
|
+
new_contents = changelog_txt.gsub(/\r\n/, "\n")
|
42
|
+
File.open(changelog_file, 'w') { |file| file.puts new_contents }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
rescue LoadError
|
46
|
+
end
|
data/features/update.feature
CHANGED
@@ -905,6 +905,28 @@ Feature: update
|
|
905
905
|
Then the output should not contain "error"
|
906
906
|
Then the output should not contain "rejected"
|
907
907
|
|
908
|
+
Scenario: Creating a GitHub PR with an update
|
909
|
+
Given a mocked git configuration
|
910
|
+
And a remote module repository
|
911
|
+
And a directory named "moduleroot"
|
912
|
+
And I set the environment variables to:
|
913
|
+
| variable | value |
|
914
|
+
| GITHUB_TOKEN | foobar |
|
915
|
+
When I run `msync update --noop --branch managed_update --pr`
|
916
|
+
Then the output should contain "Would submit PR "
|
917
|
+
And the exit status should be 0
|
918
|
+
|
919
|
+
Scenario: Creating a GitLab MR with an update
|
920
|
+
Given a mocked git configuration
|
921
|
+
And a remote module repository
|
922
|
+
And a directory named "moduleroot"
|
923
|
+
And I set the environment variables to:
|
924
|
+
| variable | value |
|
925
|
+
| GITLAB_TOKEN | foobar |
|
926
|
+
When I run `msync update --noop --branch managed_update --pr`
|
927
|
+
Then the output should contain "Would submit MR "
|
928
|
+
And the exit status should be 0
|
929
|
+
|
908
930
|
Scenario: Repository with a default branch other than master
|
909
931
|
Given a mocked git configuration
|
910
932
|
And a remote module repository with "develop" as the default branch
|
@@ -922,3 +944,36 @@ Feature: update
|
|
922
944
|
When I run `msync update -m "Update Gemfile"`
|
923
945
|
Then the exit status should be 0
|
924
946
|
Then the output should contain "Using repository's default branch: develop"
|
947
|
+
|
948
|
+
Scenario: Adding a new file from a template using meta data
|
949
|
+
And a file named "config_defaults.yml" with:
|
950
|
+
"""
|
951
|
+
---
|
952
|
+
"""
|
953
|
+
Given a file named "managed_modules.yml" with:
|
954
|
+
"""
|
955
|
+
---
|
956
|
+
- puppet-test
|
957
|
+
"""
|
958
|
+
And a file named "modulesync.yml" with:
|
959
|
+
"""
|
960
|
+
---
|
961
|
+
namespace: maestrodev
|
962
|
+
git_base: https://github.com/
|
963
|
+
"""
|
964
|
+
And a directory named "moduleroot"
|
965
|
+
And a file named "moduleroot/test.erb" with:
|
966
|
+
"""
|
967
|
+
module: <%= @metadata[:module_name] %>
|
968
|
+
target: <%= @metadata[:target_file] %>
|
969
|
+
workdir: <%= @metadata[:workdir] %>
|
970
|
+
"""
|
971
|
+
When I run `msync update --noop`
|
972
|
+
Then the exit status should be 0
|
973
|
+
Given I run `cat modules/maestrodev/puppet-test/test`
|
974
|
+
Then the output should contain:
|
975
|
+
"""
|
976
|
+
module: puppet-test
|
977
|
+
target: modules/maestrodev/puppet-test/test
|
978
|
+
workdir: modules/maestrodev/puppet-test
|
979
|
+
"""
|
data/lib/modulesync.rb
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
require 'fileutils'
|
2
|
-
require 'octokit'
|
3
2
|
require 'pathname'
|
4
3
|
require 'modulesync/cli'
|
5
4
|
require 'modulesync/constants'
|
@@ -10,13 +9,7 @@ require 'modulesync/settings'
|
|
10
9
|
require 'modulesync/util'
|
11
10
|
require 'monkey_patches'
|
12
11
|
|
13
|
-
|
14
|
-
|
15
|
-
Octokit.configure do |c|
|
16
|
-
c.api_endpoint = ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com')
|
17
|
-
end
|
18
|
-
|
19
|
-
module ModuleSync
|
12
|
+
module ModuleSync # rubocop:disable Metrics/ModuleLength
|
20
13
|
include Constants
|
21
14
|
|
22
15
|
def self.config_defaults
|
@@ -32,8 +25,8 @@ module ModuleSync
|
|
32
25
|
File.join(config_path, MODULE_FILES_DIR, file)
|
33
26
|
end
|
34
27
|
|
35
|
-
def self.module_file(project_root, namespace, puppet_module,
|
36
|
-
File.join(project_root, namespace, puppet_module,
|
28
|
+
def self.module_file(project_root, namespace, puppet_module, *parts)
|
29
|
+
File.join(project_root, namespace, puppet_module, *parts)
|
37
30
|
end
|
38
31
|
|
39
32
|
# List all template files.
|
@@ -46,7 +39,7 @@ module ModuleSync
|
|
46
39
|
.collect { |p| p.chomp('.erb') }
|
47
40
|
.to_a
|
48
41
|
else
|
49
|
-
puts "#{local_template_dir} does not exist." \
|
42
|
+
$stdout.puts "#{local_template_dir} does not exist." \
|
50
43
|
' Check that you are working in your module configs directory or' \
|
51
44
|
' that you have passed in the correct directory with -c.'
|
52
45
|
exit
|
@@ -60,7 +53,7 @@ module ModuleSync
|
|
60
53
|
def self.managed_modules(config_file, filter, negative_filter)
|
61
54
|
managed_modules = Util.parse_config(config_file)
|
62
55
|
if managed_modules.empty?
|
63
|
-
puts "No modules found in #{config_file}." \
|
56
|
+
$stdout.puts "No modules found in #{config_file}." \
|
64
57
|
' Check that you specified the right :configs directory and :managed_modules_conf file.'
|
65
58
|
exit
|
66
59
|
end
|
@@ -89,28 +82,34 @@ module ModuleSync
|
|
89
82
|
namespace = settings.additional_settings[:namespace]
|
90
83
|
module_name = settings.additional_settings[:puppet_module]
|
91
84
|
configs = settings.build_file_configs(filename)
|
85
|
+
target_file = module_file(options[:project_root], namespace, module_name, filename)
|
92
86
|
if configs['delete']
|
93
|
-
Renderer.remove(
|
87
|
+
Renderer.remove(target_file)
|
94
88
|
else
|
95
89
|
templatename = local_file(options[:configs], filename)
|
96
90
|
begin
|
97
91
|
erb = Renderer.build(templatename)
|
98
|
-
template
|
99
|
-
|
92
|
+
# Meta data passed to the template as @metadata[:name]
|
93
|
+
metadata = {
|
94
|
+
:module_name => module_name,
|
95
|
+
:workdir => module_file(options[:project_root], namespace, module_name),
|
96
|
+
:target_file => target_file,
|
97
|
+
}
|
98
|
+
template = Renderer.render(erb, configs, metadata)
|
99
|
+
Renderer.sync(template, target_file)
|
100
100
|
rescue # rubocop:disable Lint/RescueWithoutErrorClass
|
101
|
-
|
101
|
+
$stderr.puts "Error while rendering #{filename}"
|
102
102
|
raise
|
103
103
|
end
|
104
104
|
end
|
105
105
|
end
|
106
106
|
|
107
107
|
def self.manage_module(puppet_module, module_files, module_options, defaults, options)
|
108
|
-
|
109
|
-
|
110
|
-
|
108
|
+
default_namespace = options[:namespace]
|
109
|
+
if module_options.is_a?(Hash) && module_options.key?(:namespace)
|
110
|
+
default_namespace = module_options[:namespace]
|
111
111
|
end
|
112
|
-
|
113
|
-
namespace, module_name = module_name(puppet_module, options[:namespace])
|
112
|
+
namespace, module_name = module_name(puppet_module, default_namespace)
|
114
113
|
git_repo = File.join(namespace, module_name)
|
115
114
|
unless options[:offline]
|
116
115
|
Git.pull(options[:git_base], git_repo, options[:branch], options[:project_root], module_options || {})
|
@@ -125,7 +124,7 @@ module ModuleSync
|
|
125
124
|
:git_base => options[:git_base],
|
126
125
|
:namespace => namespace)
|
127
126
|
settings.unmanaged_files(module_files).each do |filename|
|
128
|
-
puts "Not managing #{filename} in #{module_name}"
|
127
|
+
$stdout.puts "Not managing #{filename} in #{module_name}"
|
129
128
|
end
|
130
129
|
|
131
130
|
files_to_manage = settings.managed_files(module_files)
|
@@ -133,40 +132,39 @@ module ModuleSync
|
|
133
132
|
|
134
133
|
if options[:noop]
|
135
134
|
Git.update_noop(git_repo, options)
|
135
|
+
options[:pr] && pr(module_options).manage(namespace, module_name, options)
|
136
136
|
elsif !options[:offline]
|
137
|
-
# Git.update() returns a boolean: true if files were pushed, false if not.
|
138
137
|
pushed = Git.update(git_repo, files_to_manage, options)
|
139
|
-
|
140
|
-
|
141
|
-
# We only do GitHub PR work if the GITHUB_TOKEN variable is set in the environment.
|
142
|
-
repo_path = File.join(namespace, module_name)
|
143
|
-
puts "Submitting PR '#{options[:pr_title]}' on GitHub to #{repo_path} - merges #{options[:branch]} into master"
|
144
|
-
github = Octokit::Client.new(:access_token => GITHUB_TOKEN)
|
145
|
-
pr = github.create_pull_request(repo_path, 'master', options[:branch], options[:pr_title], options[:message])
|
146
|
-
puts "PR created at #{pr['html_url']}"
|
147
|
-
|
148
|
-
# PR labels can either be a list in the YAML file or they can pass in a comma
|
149
|
-
# separated list via the command line argument.
|
150
|
-
pr_labels = Util.parse_list(options[:pr_labels])
|
151
|
-
|
152
|
-
# We only assign labels to the PR if we've discovered a list > 1. The labels MUST
|
153
|
-
# already exist. We DO NOT create missing labels.
|
154
|
-
unless pr_labels.empty?
|
155
|
-
puts "Attaching the following labels to PR #{pr['number']}: #{pr_labels.join(', ')}"
|
156
|
-
github.add_labels_to_an_issue(repo_path, pr['number'], pr_labels)
|
157
|
-
end
|
138
|
+
pushed && options[:pr] && pr(module_options).manage(namespace, module_name, options)
|
158
139
|
end
|
159
140
|
end
|
160
141
|
|
142
|
+
def self.config_path(file, options)
|
143
|
+
return file if Pathname.new(file).absolute?
|
144
|
+
File.join(options[:configs], file)
|
145
|
+
end
|
146
|
+
|
147
|
+
def config_path(file, options)
|
148
|
+
self.class.config_path(file, options)
|
149
|
+
end
|
150
|
+
|
161
151
|
def self.update(options)
|
162
152
|
options = config_defaults.merge(options)
|
163
|
-
defaults = Util.parse_config(
|
153
|
+
defaults = Util.parse_config(config_path(CONF_FILE, options))
|
154
|
+
if options[:pr]
|
155
|
+
unless options[:branch]
|
156
|
+
$stderr.puts 'A branch must be specified with --branch to use --pr!'
|
157
|
+
raise
|
158
|
+
end
|
164
159
|
|
165
|
-
|
160
|
+
@pr = create_pr_manager if options[:pr]
|
161
|
+
end
|
162
|
+
|
163
|
+
local_template_dir = config_path(MODULE_FILES_DIR, options)
|
166
164
|
local_files = find_template_files(local_template_dir)
|
167
165
|
module_files = relative_names(local_files, local_template_dir)
|
168
166
|
|
169
|
-
managed_modules = self.managed_modules(
|
167
|
+
managed_modules = self.managed_modules(config_path(options[:managed_modules_conf], options),
|
170
168
|
options[:filter],
|
171
169
|
options[:negative_filter])
|
172
170
|
|
@@ -174,14 +172,51 @@ module ModuleSync
|
|
174
172
|
# managed_modules is either an array or a hash
|
175
173
|
managed_modules.each do |puppet_module, module_options|
|
176
174
|
begin
|
177
|
-
|
175
|
+
mod_options = module_options.nil? ? nil : Util.symbolize_keys(module_options)
|
176
|
+
manage_module(puppet_module, module_files, mod_options, defaults, options)
|
178
177
|
rescue # rubocop:disable Lint/RescueWithoutErrorClass
|
179
|
-
|
178
|
+
$stderr.puts "Error while updating #{puppet_module}"
|
180
179
|
raise unless options[:skip_broken]
|
181
180
|
errors = true
|
182
|
-
puts "Skipping #{puppet_module} as update process failed"
|
181
|
+
$stdout.puts "Skipping #{puppet_module} as update process failed"
|
183
182
|
end
|
184
183
|
end
|
185
184
|
exit 1 if errors && options[:fail_on_warnings]
|
186
185
|
end
|
186
|
+
|
187
|
+
def self.pr(module_options)
|
188
|
+
module_options ||= {}
|
189
|
+
github_conf = module_options[:github]
|
190
|
+
gitlab_conf = module_options[:gitlab]
|
191
|
+
|
192
|
+
if !github_conf.nil?
|
193
|
+
base_url = github_conf[:base_url] || ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com')
|
194
|
+
require 'modulesync/pr/github'
|
195
|
+
ModuleSync::PR::GitHub.new(github_conf[:token], base_url)
|
196
|
+
elsif !gitlab_conf.nil?
|
197
|
+
base_url = gitlab_conf[:base_url] || ENV.fetch('GITLAB_BASE_URL', 'https://gitlab.com/api/v4')
|
198
|
+
require 'modulesync/pr/gitlab'
|
199
|
+
ModuleSync::PR::GitLab.new(gitlab_conf[:token], base_url)
|
200
|
+
elsif @pr.nil?
|
201
|
+
$stderr.puts 'No GitHub or GitLab token specified for --pr!'
|
202
|
+
raise
|
203
|
+
else
|
204
|
+
@pr
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def self.create_pr_manager
|
209
|
+
github_token = ENV.fetch('GITHUB_TOKEN', '')
|
210
|
+
gitlab_token = ENV.fetch('GITLAB_TOKEN', '')
|
211
|
+
|
212
|
+
if !github_token.empty?
|
213
|
+
require 'modulesync/pr/github'
|
214
|
+
ModuleSync::PR::GitHub.new(github_token, ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com'))
|
215
|
+
elsif !gitlab_token.empty?
|
216
|
+
require 'modulesync/pr/gitlab'
|
217
|
+
ModuleSync::PR::GitLab.new(gitlab_token, ENV.fetch('GITLAB_BASE_URL', 'https://gitlab.com/api/v4'))
|
218
|
+
else
|
219
|
+
warn '--pr specified without environment variables GITHUB_TOKEN or GITLAB_TOKEN'
|
220
|
+
end
|
221
|
+
end
|
187
222
|
end
|
data/lib/modulesync/cli.rb
CHANGED
@@ -67,6 +67,8 @@ module ModuleSync
|
|
67
67
|
:aliases => '-c',
|
68
68
|
:desc => 'The local directory or remote repository to define the list of managed modules,' \
|
69
69
|
' the file templates, and the default values for template variables.'
|
70
|
+
option :managed_modules_conf,
|
71
|
+
:desc => 'The file name to define the list of managed modules'
|
70
72
|
option :remote_branch,
|
71
73
|
:aliases => '-r',
|
72
74
|
:desc => 'Remote branch name to push the changes to. Defaults to the branch name.',
|
@@ -90,14 +92,18 @@ module ModuleSync
|
|
90
92
|
:default => false
|
91
93
|
option :pr,
|
92
94
|
:type => :boolean,
|
93
|
-
:desc => 'Submit
|
95
|
+
:desc => 'Submit pull/merge request',
|
94
96
|
:default => false
|
95
97
|
option :pr_title,
|
96
|
-
:desc => 'Title of
|
98
|
+
:desc => 'Title of pull/merge request',
|
97
99
|
:default => CLI.defaults[:pr_title] || 'Update to module template files'
|
98
100
|
option :pr_labels,
|
99
|
-
:
|
101
|
+
:type => :array,
|
102
|
+
:desc => 'Labels to add to the pull/merge request',
|
100
103
|
:default => CLI.defaults[:pr_labels] || []
|
104
|
+
option :pr_target_branch,
|
105
|
+
:desc => 'Target branch for the pull/merge request',
|
106
|
+
:default => CLI.defaults[:pr_target_branch] || 'master'
|
101
107
|
option :offline,
|
102
108
|
:type => :boolean,
|
103
109
|
:desc => 'Do not run any Git commands. Allows the user to manage Git outside of ModuleSync.',
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'octokit'
|
2
|
+
require 'modulesync/util'
|
3
|
+
|
4
|
+
module ModuleSync
|
5
|
+
module PR
|
6
|
+
# GitHub creates and manages pull requests on github.com or GitHub
|
7
|
+
# Enterprise installations.
|
8
|
+
class GitHub
|
9
|
+
def initialize(token, endpoint)
|
10
|
+
Octokit.configure do |c|
|
11
|
+
c.api_endpoint = endpoint
|
12
|
+
end
|
13
|
+
@api = Octokit::Client.new(:access_token => token)
|
14
|
+
end
|
15
|
+
|
16
|
+
def manage(namespace, module_name, options)
|
17
|
+
repo_path = File.join(namespace, module_name)
|
18
|
+
branch = options[:remote_branch] || options[:branch]
|
19
|
+
head = "#{namespace}:#{branch}"
|
20
|
+
target_branch = options[:pr_target_branch] || 'master'
|
21
|
+
|
22
|
+
if options[:noop]
|
23
|
+
$stdout.puts \
|
24
|
+
"Using no-op. Would submit PR '#{options[:pr_title]}' to #{repo_path} " \
|
25
|
+
"- merges #{branch} into #{target_branch}"
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
29
|
+
pull_requests = @api.pull_requests(repo_path,
|
30
|
+
:state => 'open',
|
31
|
+
:base => target_branch,
|
32
|
+
:head => head)
|
33
|
+
unless pull_requests.empty?
|
34
|
+
# Skip creating the PR if it exists already.
|
35
|
+
$stdout.puts "Skipped! #{pull_requests.length} PRs found for branch #{branch}"
|
36
|
+
return
|
37
|
+
end
|
38
|
+
|
39
|
+
pr_labels = ModuleSync::Util.parse_list(options[:pr_labels])
|
40
|
+
pr = @api.create_pull_request(repo_path,
|
41
|
+
target_branch,
|
42
|
+
branch,
|
43
|
+
options[:pr_title],
|
44
|
+
options[:message])
|
45
|
+
$stdout.puts \
|
46
|
+
"Submitted PR '#{options[:pr_title]}' to #{repo_path} " \
|
47
|
+
"- merges #{branch} into #{target_branch}"
|
48
|
+
|
49
|
+
# We only assign labels to the PR if we've discovered a list > 1. The labels MUST
|
50
|
+
# already exist. We DO NOT create missing labels.
|
51
|
+
return if pr_labels.empty?
|
52
|
+
$stdout.puts "Attaching the following labels to PR #{pr['number']}: #{pr_labels.join(', ')}"
|
53
|
+
@api.add_labels_to_an_issue(repo_path, pr['number'], pr_labels)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'gitlab'
|
2
|
+
require 'modulesync/util'
|
3
|
+
|
4
|
+
module ModuleSync
|
5
|
+
module PR
|
6
|
+
# GitLab creates and manages merge requests on gitlab.com or private GitLab
|
7
|
+
# installations.
|
8
|
+
class GitLab
|
9
|
+
def initialize(token, endpoint)
|
10
|
+
@api = Gitlab::Client.new(
|
11
|
+
:endpoint => endpoint,
|
12
|
+
:private_token => token
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
def manage(namespace, module_name, options)
|
17
|
+
repo_path = File.join(namespace, module_name)
|
18
|
+
branch = options[:remote_branch] || options[:branch]
|
19
|
+
head = "#{namespace}:#{branch}"
|
20
|
+
target_branch = options[:pr_target_branch] || 'master'
|
21
|
+
|
22
|
+
if options[:noop]
|
23
|
+
$stdout.puts \
|
24
|
+
"Using no-op. Would submit MR '#{options[:pr_title]}' to #{repo_path} " \
|
25
|
+
"- merges #{branch} into #{target_branch}"
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
29
|
+
merge_requests = @api.merge_requests(repo_path,
|
30
|
+
:state => 'opened',
|
31
|
+
:source_branch => head,
|
32
|
+
:target_branch => target_branch)
|
33
|
+
unless merge_requests.empty?
|
34
|
+
# Skip creating the MR if it exists already.
|
35
|
+
$stdout.puts "Skipped! #{merge_requests.length} MRs found for branch #{branch}"
|
36
|
+
return
|
37
|
+
end
|
38
|
+
|
39
|
+
mr_labels = ModuleSync::Util.parse_list(options[:pr_labels])
|
40
|
+
mr = @api.create_merge_request(repo_path,
|
41
|
+
options[:pr_title],
|
42
|
+
:source_branch => branch,
|
43
|
+
:target_branch => target_branch,
|
44
|
+
:labels => mr_labels)
|
45
|
+
$stdout.puts \
|
46
|
+
"Submitted MR '#{options[:pr_title]}' to #{repo_path} " \
|
47
|
+
"- merges #{branch} into #{target_branch}"
|
48
|
+
|
49
|
+
return if mr_labels.empty?
|
50
|
+
$stdout.puts "Attached the following labels to MR #{mr.iid}: #{mr_labels.join(', ')}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|