modulesync 1.1.0 → 2.0.2

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