modulesync 0.8.2 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)