modulesync 0.8.2 → 1.2.0

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.
@@ -9,7 +9,7 @@ require 'modulesync/settings'
9
9
  require 'modulesync/util'
10
10
  require 'monkey_patches'
11
11
 
12
- module ModuleSync
12
+ module ModuleSync # rubocop:disable Metrics/ModuleLength
13
13
  include Constants
14
14
 
15
15
  def self.config_defaults
@@ -22,31 +22,39 @@ module ModuleSync
22
22
  end
23
23
 
24
24
  def self.local_file(config_path, file)
25
- "#{config_path}/#{MODULE_FILES_DIR}/#{file}"
25
+ File.join(config_path, MODULE_FILES_DIR, file)
26
26
  end
27
27
 
28
- def self.module_file(project_root, puppet_module, file)
29
- "#{project_root}/#{puppet_module}/#{file}"
28
+ def self.module_file(project_root, namespace, puppet_module, *parts)
29
+ File.join(project_root, namespace, puppet_module, *parts)
30
30
  end
31
31
 
32
- def self.local_files(path)
33
- if File.exist?(path)
34
- # only select *.erb files, and strip the extension. This way all the code will only have to handle bare paths, except when reading the actual ERB text
35
- local_files = Find.find(path).find_all { |p| p =~ /.erb$/ && !File.directory?(p) }.collect { |p| p.chomp('.erb') }.to_a
32
+ # List all template files.
33
+ #
34
+ # Only select *.erb files, and strip the extension. This way all the code will only have to handle bare paths,
35
+ # except when reading the actual ERB text
36
+ def self.find_template_files(local_template_dir)
37
+ if File.exist?(local_template_dir)
38
+ Find.find(local_template_dir).find_all { |p| p =~ /.erb$/ && !File.directory?(p) }
39
+ .collect { |p| p.chomp('.erb') }
40
+ .to_a
36
41
  else
37
- puts "#{path} does not exist. Check that you are working in your module configs directory or that you have passed in the correct directory with -c."
42
+ $stdout.puts "#{local_template_dir} does not exist." \
43
+ ' Check that you are working in your module configs directory or' \
44
+ ' that you have passed in the correct directory with -c.'
38
45
  exit
39
46
  end
40
47
  end
41
48
 
42
- def self.module_files(local_files, path)
43
- local_files.map { |file| file.sub(/#{path}/, '') }
49
+ def self.relative_names(file_list, path)
50
+ file_list.map { |file| file.sub(/#{path}/, '') }
44
51
  end
45
52
 
46
- def self.managed_modules(path, filter, negative_filter)
47
- managed_modules = Util.parse_config(path)
53
+ def self.managed_modules(config_file, filter, negative_filter)
54
+ managed_modules = Util.parse_config(config_file)
48
55
  if managed_modules.empty?
49
- puts "No modules found at #{path}. Check that you specified the right configs directory containing managed_modules.yml."
56
+ $stdout.puts "No modules found in #{config_file}." \
57
+ ' Check that you specified the right :configs directory and :managed_modules_conf file.'
50
58
  exit
51
59
  end
52
60
  managed_modules.select! { |m| m =~ Regexp.new(filter) } unless filter.nil?
@@ -71,72 +79,109 @@ module ModuleSync
71
79
  end
72
80
 
73
81
  def self.manage_file(filename, settings, options)
82
+ namespace = settings.additional_settings[:namespace]
74
83
  module_name = settings.additional_settings[:puppet_module]
75
84
  configs = settings.build_file_configs(filename)
85
+ target_file = module_file(options[:project_root], namespace, module_name, filename)
76
86
  if configs['delete']
77
- Renderer.remove(module_file(options[:project_root], module_name, filename))
87
+ Renderer.remove(target_file)
78
88
  else
79
89
  templatename = local_file(options[:configs], filename)
80
90
  begin
81
91
  erb = Renderer.build(templatename)
82
- template = Renderer.render(erb, configs)
83
- Renderer.sync(template, module_file(options[:project_root], module_name, filename))
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)
84
100
  rescue # rubocop:disable Lint/RescueWithoutErrorClass
85
- STDERR.puts "Error while rendering #{filename}"
101
+ $stderr.puts "Error while rendering #{filename}"
86
102
  raise
87
103
  end
88
104
  end
89
105
  end
90
106
 
91
107
  def self.manage_module(puppet_module, module_files, module_options, defaults, options)
92
- puts "Syncing #{puppet_module}"
93
108
  namespace, module_name = module_name(puppet_module, options[:namespace])
109
+ git_repo = File.join(namespace, module_name)
94
110
  unless options[:offline]
95
- git_base = options[:git_base]
96
- git_uri = "#{git_base}#{namespace}"
97
- Git.pull(git_uri, module_name, options[:branch], options[:project_root], module_options || {})
111
+ Git.pull(options[:git_base], git_repo, options[:branch], options[:project_root], module_options || {})
98
112
  end
99
- module_configs = Util.parse_config("#{options[:project_root]}/#{module_name}/#{MODULE_CONF_FILE}")
113
+
114
+ module_configs = Util.parse_config(module_file(options[:project_root], namespace, module_name, MODULE_CONF_FILE))
100
115
  settings = Settings.new(defaults[GLOBAL_DEFAULTS_KEY] || {},
101
116
  defaults,
102
117
  module_configs[GLOBAL_DEFAULTS_KEY] || {},
103
118
  module_configs,
104
119
  :puppet_module => module_name,
105
- :git_base => git_base,
120
+ :git_base => options[:git_base],
106
121
  :namespace => namespace)
107
122
  settings.unmanaged_files(module_files).each do |filename|
108
- puts "Not managing #{filename} in #{module_name}"
123
+ $stdout.puts "Not managing #{filename} in #{module_name}"
109
124
  end
110
125
 
111
126
  files_to_manage = settings.managed_files(module_files)
112
127
  files_to_manage.each { |filename| manage_file(filename, settings, options) }
113
128
 
114
129
  if options[:noop]
115
- Git.update_noop(module_name, options)
130
+ Git.update_noop(git_repo, options)
116
131
  elsif !options[:offline]
117
- Git.update(module_name, files_to_manage, options)
132
+ pushed = Git.update(git_repo, files_to_manage, options)
133
+ pushed && options[:pr] && @pr.manage(namespace, module_name, options)
118
134
  end
119
135
  end
120
136
 
121
137
  def self.update(options)
122
138
  options = config_defaults.merge(options)
123
- defaults = Util.parse_config("#{options[:configs]}/#{CONF_FILE}")
139
+ defaults = Util.parse_config(File.join(options[:configs], CONF_FILE))
140
+ if options[:pr]
141
+ unless options[:branch]
142
+ $stderr.puts 'A branch must be specified with --branch to use --pr!'
143
+ raise
144
+ end
145
+
146
+ @pr = create_pr_manager if options[:pr]
147
+ end
124
148
 
125
- path = "#{options[:configs]}/#{MODULE_FILES_DIR}"
126
- local_files = self.local_files(path)
127
- module_files = self.module_files(local_files, path)
149
+ local_template_dir = File.join(options[:configs], MODULE_FILES_DIR)
150
+ local_files = find_template_files(local_template_dir)
151
+ module_files = relative_names(local_files, local_template_dir)
128
152
 
129
- managed_modules = self.managed_modules("#{options[:configs]}/managed_modules.yml", options[:filter], options[:negative_filter])
153
+ managed_modules = self.managed_modules(File.join(options[:configs], options[:managed_modules_conf]),
154
+ options[:filter],
155
+ options[:negative_filter])
130
156
 
157
+ errors = false
131
158
  # managed_modules is either an array or a hash
132
159
  managed_modules.each do |puppet_module, module_options|
133
160
  begin
134
161
  manage_module(puppet_module, module_files, module_options, defaults, options)
135
162
  rescue # rubocop:disable Lint/RescueWithoutErrorClass
136
- STDERR.puts "Error while updating #{puppet_module}"
163
+ $stderr.puts "Error while updating #{puppet_module}"
137
164
  raise unless options[:skip_broken]
138
- puts "Skipping #{puppet_module} as update process failed"
165
+ errors = true
166
+ $stdout.puts "Skipping #{puppet_module} as update process failed"
139
167
  end
140
168
  end
169
+ exit 1 if errors && options[:fail_on_warnings]
170
+ end
171
+
172
+ def self.create_pr_manager
173
+ github_token = ENV.fetch('GITHUB_TOKEN', '')
174
+ gitlab_token = ENV.fetch('GITLAB_TOKEN', '')
175
+
176
+ if !github_token.empty?
177
+ require 'modulesync/pr/github'
178
+ ModuleSync::PR::GitHub.new(github_token, ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com'))
179
+ elsif !gitlab_token.empty?
180
+ require 'modulesync/pr/github'
181
+ ModuleSync::PR::GitLab.new(gitlab_token, ENV.fetch('GITLAB_BASE_URL', 'https://gitlab.com/api/v4'))
182
+ else
183
+ $stderr.puts 'Environment variables GITHUB_TOKEN or GITLAB_TOKEN must be set to use --pr!'
184
+ raise
185
+ end
141
186
  end
142
187
  end
@@ -43,7 +43,8 @@ module ModuleSync
43
43
  :default => CLI.defaults[:git_base] || 'git@github.com:'
44
44
  class_option :namespace,
45
45
  :aliases => '-n',
46
- :desc => 'Remote github namespace (user or organization) to clone from and push to. Defaults to puppetlabs',
46
+ :desc => 'Remote github namespace (user or organization) to clone from and push to.' \
47
+ ' Defaults to puppetlabs',
47
48
  :default => CLI.defaults[:namespace] || 'puppetlabs'
48
49
  class_option :filter,
49
50
  :aliases => '-f',
@@ -53,8 +54,9 @@ module ModuleSync
53
54
  :desc => 'A regular expression to skip repositories.'
54
55
  class_option :branch,
55
56
  :aliases => '-b',
56
- :desc => 'Branch name to make the changes in. Defaults to master.',
57
- :default => CLI.defaults[:branch] || 'master'
57
+ :desc => 'Branch name to make the changes in.' \
58
+ ' Defaults to the default branch of the upstream repository, but falls back to "master".',
59
+ :default => CLI.defaults[:branch]
58
60
 
59
61
  desc 'update', 'Update the modules in managed_modules.yml'
60
62
  option :message,
@@ -63,7 +65,8 @@ module ModuleSync
63
65
  :default => CLI.defaults[:message]
64
66
  option :configs,
65
67
  :aliases => '-c',
66
- :desc => 'The local directory or remote repository to define the list of managed modules, the file templates, and the default values for template variables.'
68
+ :desc => 'The local directory or remote repository to define the list of managed modules,' \
69
+ ' the file templates, and the default values for template variables.'
67
70
  option :remote_branch,
68
71
  :aliases => '-r',
69
72
  :desc => 'Remote branch name to push the changes to. Defaults to the branch name.',
@@ -85,6 +88,17 @@ module ModuleSync
85
88
  :type => :boolean,
86
89
  :desc => 'No-op mode',
87
90
  :default => false
91
+ option :pr,
92
+ :type => :boolean,
93
+ :desc => 'Submit pull/merge request',
94
+ :default => false
95
+ option :pr_title,
96
+ :desc => 'Title of pull/merge request',
97
+ :default => CLI.defaults[:pr_title] || 'Update to module template files'
98
+ option :pr_labels,
99
+ :type => :array,
100
+ :desc => 'Labels to add to the pull/merge request',
101
+ :default => CLI.defaults[:pr_labels] || []
88
102
  option :offline,
89
103
  :type => :boolean,
90
104
  :desc => 'Do not run any Git commands. Allows the user to manage Git outside of ModuleSync.',
@@ -104,13 +118,21 @@ module ModuleSync
104
118
  option :tag_pattern,
105
119
  :desc => 'The pattern to use when tagging releases.'
106
120
  option :pre_commit_script,
107
- :desc => 'A script to be run before commiting',
121
+ :desc => 'A script to be run before committing',
108
122
  :default => CLI.defaults[:pre_commit_script]
123
+ option :fail_on_warnings,
124
+ :type => :boolean,
125
+ :aliases => '-F',
126
+ :desc => 'Produce a failure exit code when there are warnings' \
127
+ ' (only has effect when --skip_broken is enabled)',
128
+ :default => false
109
129
 
110
130
  def update
111
131
  config = { :command => 'update' }.merge(options)
112
132
  config = Util.symbolize_keys(config)
113
- raise Thor::Error, 'No value provided for required option "--message"' unless config[:noop] || config[:message] || config[:offline]
133
+ raise Thor::Error, 'No value provided for required option "--message"' unless config[:noop] \
134
+ || config[:message] \
135
+ || config[:offline]
114
136
  config[:git_opts] = { 'amend' => config[:amend], 'force' => config[:force] }
115
137
  ModuleSync.update(config)
116
138
  end
@@ -2,7 +2,7 @@ require 'git'
2
2
  require 'puppet_blacksmith'
3
3
 
4
4
  module ModuleSync
5
- module Git
5
+ module Git # rubocop:disable Metrics/ModuleLength
6
6
  include Constants
7
7
 
8
8
  def self.remote_branch_exists?(repo, branch)
@@ -18,7 +18,17 @@ module ModuleSync
18
18
  repo.diff("#{local_branch}..origin/#{remote_branch}").any?
19
19
  end
20
20
 
21
+ def self.default_branch(repo)
22
+ symbolic_ref = repo.branches.find { |b| b.full =~ %r{remotes/origin/HEAD} }
23
+ return unless symbolic_ref
24
+ %r{remotes/origin/HEAD\s+->\s+origin/(?<branch>.+?)$}.match(symbolic_ref.full)[:branch]
25
+ end
26
+
21
27
  def self.switch_branch(repo, branch)
28
+ unless branch
29
+ branch = default_branch(repo)
30
+ puts "Using repository's default branch: #{branch}"
31
+ end
22
32
  return if repo.current_branch == branch
23
33
 
24
34
  if local_branch_exists?(repo, branch)
@@ -36,12 +46,13 @@ module ModuleSync
36
46
  end
37
47
 
38
48
  def self.pull(git_base, name, branch, project_root, opts)
49
+ puts "Syncing #{name}"
39
50
  Dir.mkdir(project_root) unless Dir.exist?(project_root)
40
51
 
41
52
  # Repo needs to be cloned in the cwd
42
53
  if !Dir.exist?("#{project_root}/#{name}") || !Dir.exist?("#{project_root}/#{name}/.git")
43
54
  puts 'Cloning repository fresh'
44
- remote = opts[:remote] || (git_base.start_with?('file://') ? "#{git_base}/#{name}" : "#{git_base}/#{name}.git")
55
+ remote = opts[:remote] || (git_base.start_with?('file://') ? "#{git_base}#{name}" : "#{git_base}#{name}.git")
45
56
  local = "#{project_root}/#{name}"
46
57
  puts "Cloning from #{remote}"
47
58
  repo = ::Git.clone(remote, local)
@@ -95,12 +106,19 @@ module ModuleSync
95
106
  repo.push('origin', tag)
96
107
  end
97
108
 
109
+ def self.checkout_branch(repo, branch)
110
+ selected_branch = branch || repo.current_branch || 'master'
111
+ repo.branch(selected_branch).checkout
112
+ selected_branch
113
+ end
114
+ private_class_method :checkout_branch
115
+
98
116
  # Git add/rm, git commit, git push
99
117
  def self.update(name, files, options)
100
118
  module_root = "#{options[:project_root]}/#{name}"
101
119
  message = options[:message]
102
120
  repo = ::Git.open(module_root)
103
- repo.branch(options[:branch]).checkout
121
+ branch = checkout_branch(repo, options[:branch])
104
122
  files.each do |file|
105
123
  if repo.status.deleted.include?(file)
106
124
  repo.remove(file)
@@ -119,11 +137,11 @@ module ModuleSync
119
137
  end
120
138
  repo.commit(message, opts_commit)
121
139
  if options[:remote_branch]
122
- if remote_branch_differ?(repo, options[:branch], options[:remote_branch])
123
- repo.push('origin', "#{options[:branch]}:#{options[:remote_branch]}", opts_push)
140
+ if remote_branch_differ?(repo, branch, options[:remote_branch])
141
+ repo.push('origin', "#{branch}:#{options[:remote_branch]}", opts_push)
124
142
  end
125
143
  else
126
- repo.push('origin', options[:branch], opts_push)
144
+ repo.push('origin', branch, opts_push)
127
145
  end
128
146
  # Only bump/tag if pushing didn't fail (i.e. there were changes)
129
147
  m = Blacksmith::Modulefile.new("#{module_root}/metadata.json")
@@ -134,11 +152,14 @@ module ModuleSync
134
152
  rescue ::Git::GitExecuteError => git_error
135
153
  if git_error.message =~ /working (directory|tree) clean/
136
154
  puts "There were no files to update in #{name}. Not committing."
155
+ return false
137
156
  else
138
157
  puts git_error
139
- exit(1)
158
+ raise
140
159
  end
141
160
  end
161
+
162
+ true
142
163
  end
143
164
 
144
165
  # Needed because of a bug in the git gem that lists ignored files as
@@ -154,7 +175,7 @@ module ModuleSync
154
175
  puts "Using no-op. Files in #{name} may be changed but will not be committed."
155
176
 
156
177
  repo = ::Git.open("#{options[:project_root]}/#{name}")
157
- repo.branch(options[:branch]).checkout
178
+ checkout_branch(repo, options[:branch])
158
179
 
159
180
  puts 'Files changed:'
160
181
  repo.diff('HEAD', '--').each do |diff|
@@ -0,0 +1,41 @@
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
+ head = "#{namespace}:#{options[:branch]}"
19
+
20
+ pull_requests = @api.pull_requests(repo_path, :state => 'open', :base => 'master', :head => head)
21
+ if pull_requests.empty?
22
+ pr = @api.create_pull_request(repo_path, 'master', options[:branch], options[:pr_title], options[:message])
23
+ $stdout.puts "Submitted PR '#{options[:pr_title]}' to #{repo_path} - merges #{options[:branch]} into master"
24
+ else
25
+ # Skip creating the PR if it exists already.
26
+ $stdout.puts "Skipped! #{pull_requests.length} PRs found for branch #{options[:branch]}"
27
+ end
28
+
29
+ # PR labels can either be a list in the YAML file or they can pass in a comma
30
+ # separated list via the command line argument.
31
+ pr_labels = ModuleSync::Util.parse_list(options[:pr_labels])
32
+
33
+ # We only assign labels to the PR if we've discovered a list > 1. The labels MUST
34
+ # already exist. We DO NOT create missing labels.
35
+ return if pr_labels.empty?
36
+ $stdout.puts "Attaching the following labels to PR #{pr['number']}: #{pr_labels.join(', ')}"
37
+ @api.add_labels_to_an_issue(repo_path, pr['number'], pr_labels)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
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
+
19
+ head = "#{namespace}:#{options[:branch]}"
20
+ merge_requests = @api.merge_requests(repo_path,
21
+ :state => 'opened',
22
+ :source_branch => head,
23
+ :target_branch => 'master')
24
+ if merge_requests.empty?
25
+ mr_labels = ModuleSync::Util.parse_list(options[:pr_labels])
26
+ mr = @api.create_merge_request(repo_path, options[:pr_title],
27
+ :source_branch => options[:branch],
28
+ :target_branch => 'master',
29
+ :labels => mr_labels)
30
+ $stdout.puts "Submitted MR '#{options[:pr_title]}' to #{repo_path} - merges #{options[:branch]} into master"
31
+ return if mr_labels.empty?
32
+ $stdout.puts "Attached the following labels to MR #{mr.iid}: #{mr_labels.join(', ')}"
33
+ else
34
+ # Skip creating the MR if it exists already.
35
+ $stdout.puts "Skipped! #{merge_requests.length} MRs found for branch #{options[:branch]}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -4,8 +4,9 @@ require 'find'
4
4
  module ModuleSync
5
5
  module Renderer
6
6
  class ForgeModuleFile
7
- def initialize(configs = {})
7
+ def initialize(configs = {}, metadata = {})
8
8
  @configs = configs
9
+ @metadata = metadata
9
10
  end
10
11
  end
11
12
 
@@ -26,8 +27,8 @@ module ModuleSync
26
27
  File.delete(file) if File.exist?(file)
27
28
  end
28
29
 
29
- def self.render(_template, configs = {})
30
- ForgeModuleFile.new(configs).render
30
+ def self.render(_template, configs = {}, metadata = {})
31
+ ForgeModuleFile.new(configs, metadata).render
31
32
  end
32
33
 
33
34
  def self.sync(template, target_name)