modulesync 1.1.0 → 2.0.2

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.
@@ -0,0 +1,26 @@
1
+ Feature: Run `msync update` without a good context
2
+
3
+ Scenario: Run `msync update` without any module
4
+ Given a directory named "moduleroot"
5
+ When I run `msync update --message "In a bad context"`
6
+ Then the exit status should be 1
7
+ And the stderr should contain:
8
+ """
9
+ No modules found
10
+ """
11
+
12
+ Scenario: Run `msync update` without the "moduleroot" directory
13
+ Given a basic setup with a puppet module "puppet-test" from "fakenamespace"
14
+ When I run `msync update --message "In a bad context"`
15
+ Then the exit status should be 1
16
+ And the stderr should contain "moduleroot"
17
+
18
+ Scenario: Run `msync update` without commit message
19
+ Given a basic setup with a puppet module "puppet-test" from "fakenamespace"
20
+ And a directory named "moduleroot"
21
+ When I run `msync update`
22
+ Then the exit status should be 1
23
+ And the stderr should contain:
24
+ """
25
+ No value provided for required option "--message"
26
+ """
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,12 +9,6 @@ require 'modulesync/settings'
10
9
  require 'modulesync/util'
11
10
  require 'monkey_patches'
12
11
 
13
- GITHUB_TOKEN = ENV.fetch('GITHUB_TOKEN', '')
14
-
15
- Octokit.configure do |c|
16
- c.api_endpoint = ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com')
17
- end
18
-
19
12
  module ModuleSync # rubocop:disable Metrics/ModuleLength
20
13
  include Constants
21
14
 
@@ -46,10 +39,10 @@ module ModuleSync # rubocop:disable Metrics/ModuleLength
46
39
  .collect { |p| p.chomp('.erb') }
47
40
  .to_a
48
41
  else
49
- $stdout.puts "#{local_template_dir} does not exist." \
42
+ $stderr.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
- exit
45
+ exit 1
53
46
  end
54
47
  end
55
48
 
@@ -60,9 +53,9 @@ module ModuleSync # rubocop:disable Metrics/ModuleLength
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
- $stdout.puts "No modules found in #{config_file}." \
56
+ $stderr.puts "No modules found in #{config_file}." \
64
57
  ' Check that you specified the right :configs directory and :managed_modules_conf file.'
65
- exit
58
+ exit 1
66
59
  end
67
60
  managed_modules.select! { |m| m =~ Regexp.new(filter) } unless filter.nil?
68
61
  managed_modules.reject! { |m| m =~ Regexp.new(negative_filter) } unless negative_filter.nil?
@@ -112,7 +105,11 @@ module ModuleSync # rubocop:disable Metrics/ModuleLength
112
105
  end
113
106
 
114
107
  def self.manage_module(puppet_module, module_files, module_options, defaults, options)
115
- namespace, module_name = module_name(puppet_module, options[:namespace])
108
+ default_namespace = options[:namespace]
109
+ if module_options.is_a?(Hash) && module_options.key?(:namespace)
110
+ default_namespace = module_options[:namespace]
111
+ end
112
+ namespace, module_name = module_name(puppet_module, default_namespace)
116
113
  git_repo = File.join(namespace, module_name)
117
114
  unless options[:offline]
118
115
  Git.pull(options[:git_base], git_repo, options[:branch], options[:project_root], module_options || {})
@@ -135,55 +132,39 @@ module ModuleSync # rubocop:disable Metrics/ModuleLength
135
132
 
136
133
  if options[:noop]
137
134
  Git.update_noop(git_repo, options)
135
+ options[:pr] && pr(module_options).manage(namespace, module_name, options)
138
136
  elsif !options[:offline]
139
- # Git.update() returns a boolean: true if files were pushed, false if not.
140
137
  pushed = Git.update(git_repo, files_to_manage, options)
141
- return nil unless pushed && options[:pr]
142
-
143
- manage_pr(namespace, module_name, options)
138
+ pushed && options[:pr] && pr(module_options).manage(namespace, module_name, options)
144
139
  end
145
140
  end
146
141
 
147
- def self.manage_pr(namespace, module_name, options)
148
- if options[:pr] && GITHUB_TOKEN.empty?
149
- $stderr.puts 'Environment variable GITHUB_TOKEN must be set to use --pr!'
150
- raise unless options[:skip_broken]
151
- end
152
-
153
- # We only do GitHub PR work if the GITHUB_TOKEN variable is set in the environment.
154
- repo_path = File.join(namespace, module_name)
155
- github = Octokit::Client.new(:access_token => GITHUB_TOKEN)
156
-
157
- # Skip creating the PR if it exists already.
158
- head = "#{namespace}:#{options[:branch]}"
159
- pull_requests = github.pull_requests(repo_path, :state => 'open', :base => 'master', :head => head)
160
- if pull_requests.empty?
161
- pr = github.create_pull_request(repo_path, 'master', options[:branch], options[:pr_title], options[:message])
162
- $stdout.puts "Submitted PR '#{options[:pr_title]}' to #{repo_path} - merges #{options[:branch]} into master"
163
- else
164
- $stdout.puts "Skipped! #{pull_requests.length} PRs found for branch #{options[:branch]}"
165
- end
166
-
167
- # PR labels can either be a list in the YAML file or they can pass in a comma
168
- # separated list via the command line argument.
169
- pr_labels = Util.parse_list(options[:pr_labels])
142
+ def self.config_path(file, options)
143
+ return file if Pathname.new(file).absolute?
144
+ File.join(options[:configs], file)
145
+ end
170
146
 
171
- # We only assign labels to the PR if we've discovered a list > 1. The labels MUST
172
- # already exist. We DO NOT create missing labels.
173
- return if pr_labels.empty?
174
- $stdout.puts "Attaching the following labels to PR #{pr['number']}: #{pr_labels.join(', ')}"
175
- github.add_labels_to_an_issue(repo_path, pr['number'], pr_labels)
147
+ def config_path(file, options)
148
+ self.class.config_path(file, options)
176
149
  end
177
150
 
178
151
  def self.update(options)
179
152
  options = config_defaults.merge(options)
180
- defaults = Util.parse_config(File.join(options[:configs], CONF_FILE))
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
159
+
160
+ @pr = create_pr_manager if options[:pr]
161
+ end
181
162
 
182
- local_template_dir = File.join(options[:configs], MODULE_FILES_DIR)
163
+ local_template_dir = config_path(MODULE_FILES_DIR, options)
183
164
  local_files = find_template_files(local_template_dir)
184
165
  module_files = relative_names(local_files, local_template_dir)
185
166
 
186
- managed_modules = self.managed_modules(File.join(options[:configs], options[:managed_modules_conf]),
167
+ managed_modules = self.managed_modules(config_path(options[:managed_modules_conf], options),
187
168
  options[:filter],
188
169
  options[:negative_filter])
189
170
 
@@ -191,7 +172,8 @@ module ModuleSync # rubocop:disable Metrics/ModuleLength
191
172
  # managed_modules is either an array or a hash
192
173
  managed_modules.each do |puppet_module, module_options|
193
174
  begin
194
- manage_module(puppet_module, module_files, module_options, defaults, options)
175
+ mod_options = module_options.nil? ? nil : Util.symbolize_keys(module_options)
176
+ manage_module(puppet_module, module_files, mod_options, defaults, options)
195
177
  rescue # rubocop:disable Lint/RescueWithoutErrorClass
196
178
  $stderr.puts "Error while updating #{puppet_module}"
197
179
  raise unless options[:skip_broken]
@@ -201,4 +183,40 @@ module ModuleSync # rubocop:disable Metrics/ModuleLength
201
183
  end
202
184
  exit 1 if errors && options[:fail_on_warnings]
203
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
204
222
  end
@@ -1,10 +1,12 @@
1
1
  require 'thor'
2
+
2
3
  require 'modulesync'
4
+ require 'modulesync/cli/thor'
3
5
  require 'modulesync/constants'
4
6
  require 'modulesync/util'
5
7
 
6
8
  module ModuleSync
7
- class CLI
9
+ module CLI
8
10
  def self.defaults
9
11
  @defaults ||= Util.symbolize_keys(Util.parse_config(Constants::MODULESYNC_CONF_FILE))
10
12
  end
@@ -36,15 +38,14 @@ module ModuleSync
36
38
  class Base < Thor
37
39
  class_option :project_root,
38
40
  :aliases => '-c',
39
- :desc => 'Path used by git to clone modules into. Defaults to "modules"',
41
+ :desc => 'Path used by git to clone modules into.',
40
42
  :default => CLI.defaults[:project_root] || 'modules'
41
43
  class_option :git_base,
42
44
  :desc => 'Specify the base part of a git URL to pull from',
43
45
  :default => CLI.defaults[:git_base] || 'git@github.com:'
44
46
  class_option :namespace,
45
47
  :aliases => '-n',
46
- :desc => 'Remote github namespace (user or organization) to clone from and push to.' \
47
- ' Defaults to puppetlabs',
48
+ :desc => 'Remote github namespace (user or organization) to clone from and push to.',
48
49
  :default => CLI.defaults[:namespace] || 'puppetlabs'
49
50
  class_option :filter,
50
51
  :aliases => '-f',
@@ -67,6 +68,8 @@ module ModuleSync
67
68
  :aliases => '-c',
68
69
  :desc => 'The local directory or remote repository to define the list of managed modules,' \
69
70
  ' the file templates, and the default values for template variables.'
71
+ option :managed_modules_conf,
72
+ :desc => 'The file name to define the list of managed modules'
70
73
  option :remote_branch,
71
74
  :aliases => '-r',
72
75
  :desc => 'Remote branch name to push the changes to. Defaults to the branch name.',
@@ -90,15 +93,18 @@ module ModuleSync
90
93
  :default => false
91
94
  option :pr,
92
95
  :type => :boolean,
93
- :desc => 'Submit GitHub PR',
96
+ :desc => 'Submit pull/merge request',
94
97
  :default => false
95
98
  option :pr_title,
96
- :desc => 'Title of GitHub PR',
99
+ :desc => 'Title of pull/merge request',
97
100
  :default => CLI.defaults[:pr_title] || 'Update to module template files'
98
101
  option :pr_labels,
99
102
  :type => :array,
100
- :desc => 'Labels to add to the GitHub PR',
103
+ :desc => 'Labels to add to the pull/merge request',
101
104
  :default => CLI.defaults[:pr_labels] || []
105
+ option :pr_target_branch,
106
+ :desc => 'Target branch for the pull/merge request',
107
+ :default => CLI.defaults[:pr_target_branch] || 'master'
102
108
  option :offline,
103
109
  :type => :boolean,
104
110
  :desc => 'Do not run any Git commands. Allows the user to manage Git outside of ModuleSync.',
@@ -0,0 +1,24 @@
1
+ require 'thor'
2
+ require 'modulesync/cli'
3
+
4
+ module ModuleSync
5
+ module CLI
6
+ # Workaround some, still unfixed, Thor behaviors
7
+ #
8
+ # This class extends ::Thor class to
9
+ # - exit with status code sets to `1` on Thor failure (e.g. missing required option)
10
+ # - exit with status code sets to `1` when user calls `msync` (or a subcommand) without required arguments
11
+ class Thor < ::Thor
12
+ desc '_invalid_command_call', 'Invalid command', hide: true
13
+ def _invalid_command_call
14
+ self.class.new.help
15
+ exit 1
16
+ end
17
+ default_task :_invalid_command_call
18
+
19
+ def self.exit_on_failure?
20
+ true
21
+ end
22
+ end
23
+ end
24
+ end
@@ -150,7 +150,7 @@ module ModuleSync
150
150
  tag(repo, new, options[:tag_pattern]) if options[:tag]
151
151
  end
152
152
  rescue ::Git::GitExecuteError => git_error
153
- if git_error.message =~ /working (directory|tree) clean/
153
+ if git_error.message.match?(/working (directory|tree) clean/)
154
154
  puts "There were no files to update in #{name}. Not committing."
155
155
  return false
156
156
  else
@@ -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
@@ -3,7 +3,10 @@ require 'yaml'
3
3
  module ModuleSync
4
4
  module Util
5
5
  def self.symbolize_keys(hash)
6
- hash.inject({}) { |memo, (k, v)| memo[k.to_sym] = v; memo }
6
+ hash.inject({}) do |memo, (k, v)|
7
+ memo[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v
8
+ memo
9
+ end
7
10
  end
8
11
 
9
12
  def self.parse_config(config_file)
@@ -1,55 +1,16 @@
1
1
  module Git
2
- class Diff
3
- # Monkey patch process_full_diff until https://github.com/schacon/ruby-git/issues/326 is resolved
4
- def process_full_diff
5
- defaults = {
6
- :mode => '',
7
- :src => '',
8
- :dst => '',
9
- :type => 'modified'
10
- }
11
- final = {}
12
- current_file = nil
13
- full_diff_utf8_encoded = @full_diff.encode("UTF-8", "binary", {
14
- :invalid => :replace,
15
- :undef => :replace
16
- })
17
- full_diff_utf8_encoded.split("\n").each do |line|
18
- if m = /^diff --git a\/(.*?) b\/(.*?)/.match(line)
19
- current_file = m[1]
20
- final[current_file] = defaults.merge({:patch => line, :path => current_file})
21
- elsif !current_file.nil?
22
- if m = /^index (.......)\.\.(.......)( ......)*/.match(line)
23
- final[current_file][:src] = m[1]
24
- final[current_file][:dst] = m[2]
25
- final[current_file][:mode] = m[3].strip if m[3]
26
- end
27
- if m = /^([[:alpha:]]*?) file mode (......)/.match(line)
28
- final[current_file][:type] = m[1]
29
- final[current_file][:mode] = m[2]
30
- end
31
- if m = /^Binary files /.match(line)
32
- final[current_file][:binary] = true
33
- end
34
- final[current_file][:patch] << "\n" + line
35
- end
36
- end
37
- final.map { |e| [e[0], DiffFile.new(@base, e[1])] }
2
+ module LibMonkeyPatch
3
+ # Monkey patch set_custom_git_env_variables due to our ::Git::GitExecuteError handling.
4
+ #
5
+ # We rescue on the GitExecuteError and proceed differently based on the output of git.
6
+ # This way makes code language-dependent, so here we ensure that Git gem throw git commands with the "C" language
7
+ def set_custom_git_env_variables
8
+ super
9
+ ENV['LANG'] = 'C.UTF-8'
38
10
  end
39
11
  end
40
12
 
41
13
  class Lib
42
- # Monkey patch ls_files until https://github.com/ruby-git/ruby-git/pull/320 is resolved
43
- def ls_files(location=nil)
44
- location ||= '.'
45
- hsh = {}
46
- command_lines('ls-files', ['--stage', location]).each do |line|
47
- (info, file) = line.split("\t")
48
- (mode, sha, stage) = info.split
49
- file = eval(file) if file =~ /^\".*\"$/ # This takes care of quoted strings returned from git
50
- hsh[file] = {:path => file, :mode_index => mode, :sha_index => sha, :stage => stage}
51
- end
52
- hsh
53
- end
14
+ prepend LibMonkeyPatch
54
15
  end
55
16
  end