modulesync 1.2.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -15,25 +15,39 @@ module ModuleSync
15
15
 
16
16
  def manage(namespace, module_name, options)
17
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
18
28
 
19
- head = "#{namespace}:#{options[:branch]}"
20
29
  merge_requests = @api.merge_requests(repo_path,
21
30
  :state => 'opened',
22
31
  :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
32
+ :target_branch => target_branch)
33
+ unless merge_requests.empty?
34
34
  # Skip creating the MR if it exists already.
35
- $stdout.puts "Skipped! #{merge_requests.length} MRs found for branch #{options[:branch]}"
35
+ $stdout.puts "Skipped! #{merge_requests.length} MRs found for branch #{branch}"
36
+ return
36
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(', ')}"
37
51
  end
38
52
  end
39
53
  end
@@ -0,0 +1,37 @@
1
+ require 'puppet_blacksmith'
2
+
3
+ require 'modulesync/source_code'
4
+
5
+ module ModuleSync
6
+ # Provide methods to manipulate puppet module code
7
+ class PuppetModule < SourceCode
8
+ def update_changelog(version, message)
9
+ changelog = path('CHANGELOG.md')
10
+ if File.exist?(changelog)
11
+ puts "Updating #{changelog} for version #{version}"
12
+ changes = File.readlines(changelog)
13
+ File.open(changelog, 'w') do |f|
14
+ date = Time.now.strftime('%Y-%m-%d')
15
+ f.puts "## #{date} - Release #{version}\n\n"
16
+ f.puts "#{message}\n\n"
17
+ # Add old lines again
18
+ f.puts changes
19
+ end
20
+ repository.git.add('CHANGELOG.md')
21
+ else
22
+ puts 'No CHANGELOG.md file found, not updating.'
23
+ end
24
+ end
25
+
26
+ def bump(message, changelog = false)
27
+ m = Blacksmith::Modulefile.new path('metadata.json')
28
+ new = m.bump!
29
+ puts "Bumped to version #{new}"
30
+ repository.git.add('metadata.json')
31
+ update_changelog(new, message) if changelog
32
+ repository.git.commit("Release version #{new}")
33
+ repository.git.push
34
+ new
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,158 @@
1
+ require 'git'
2
+
3
+ module ModuleSync
4
+ # Wrapper for Git in ModuleSync context
5
+ class Repository
6
+ def initialize(directory:, remote:)
7
+ @directory = directory
8
+ @remote = remote
9
+ end
10
+
11
+ def git
12
+ @git ||= Git.open @directory
13
+ end
14
+
15
+ # This is an alias to minimize code alteration
16
+ def repo
17
+ git
18
+ end
19
+
20
+ def remote_branch_exists?(branch)
21
+ repo.branches.remote.collect(&:name).include?(branch)
22
+ end
23
+
24
+ def local_branch_exists?(branch)
25
+ repo.branches.local.collect(&:name).include?(branch)
26
+ end
27
+
28
+ def remote_branch_differ?(local_branch, remote_branch)
29
+ !remote_branch_exists?(remote_branch) ||
30
+ repo.diff("#{local_branch}..origin/#{remote_branch}").any?
31
+ end
32
+
33
+ def default_branch
34
+ symbolic_ref = repo.branches.find { |b| b.full =~ %r{remotes/origin/HEAD} }
35
+ return unless symbolic_ref
36
+ %r{remotes/origin/HEAD\s+->\s+origin/(?<branch>.+?)$}.match(symbolic_ref.full)[:branch]
37
+ end
38
+
39
+ def switch_branch(branch)
40
+ unless branch
41
+ branch = default_branch
42
+ puts "Using repository's default branch: #{branch}"
43
+ end
44
+ return if repo.current_branch == branch
45
+
46
+ if local_branch_exists?(branch)
47
+ puts "Switching to branch #{branch}"
48
+ repo.checkout(branch)
49
+ elsif remote_branch_exists?(branch)
50
+ puts "Creating local branch #{branch} from origin/#{branch}"
51
+ repo.checkout("origin/#{branch}")
52
+ repo.branch(branch).checkout
53
+ else
54
+ repo.checkout('origin/master')
55
+ puts "Creating new branch #{branch}"
56
+ repo.branch(branch).checkout
57
+ end
58
+ end
59
+
60
+ def prepare_workspace(branch)
61
+ # Repo needs to be cloned in the cwd
62
+ if !Dir.exist?("#{@directory}/.git")
63
+ puts 'Cloning repository fresh'
64
+ puts "Cloning from '#{@remote}'"
65
+ @git = Git.clone(@remote, @directory)
66
+ switch_branch(branch)
67
+ # Repo already cloned, check out master and override local changes
68
+ else
69
+ # Some versions of git can't properly handle managing a repo from outside the repo directory
70
+ Dir.chdir(@directory) do
71
+ puts "Overriding any local changes to repository in '#{@directory}'"
72
+ @git = Git.open('.')
73
+ repo.fetch
74
+ repo.reset_hard
75
+ switch_branch(branch)
76
+ git.pull('origin', branch) if remote_branch_exists?(branch)
77
+ end
78
+ end
79
+ end
80
+
81
+ def tag(version, tag_pattern)
82
+ tag = tag_pattern % version
83
+ puts "Tagging with #{tag}"
84
+ repo.add_tag(tag)
85
+ repo.push('origin', tag)
86
+ end
87
+
88
+ def checkout_branch(branch)
89
+ selected_branch = branch || repo.current_branch || 'master'
90
+ repo.branch(selected_branch).checkout
91
+ selected_branch
92
+ end
93
+
94
+ # Git add/rm, git commit, git push
95
+ def submit_changes(files, options)
96
+ message = options[:message]
97
+ branch = checkout_branch(options[:branch])
98
+ files.each do |file|
99
+ if repo.status.deleted.include?(file)
100
+ repo.remove(file)
101
+ elsif File.exist?("#{@directory}/#{file}")
102
+ repo.add(file)
103
+ end
104
+ end
105
+ begin
106
+ opts_commit = {}
107
+ opts_push = {}
108
+ opts_commit = { :amend => true } if options[:amend]
109
+ opts_push = { :force => true } if options[:force]
110
+ if options[:pre_commit_script]
111
+ script = "#{File.dirname(File.dirname(__FILE__))}/../contrib/#{options[:pre_commit_script]}"
112
+ `#{script} #{@directory}`
113
+ end
114
+ repo.commit(message, opts_commit)
115
+ if options[:remote_branch]
116
+ if remote_branch_differ?(branch, options[:remote_branch])
117
+ repo.push('origin', "#{branch}:#{options[:remote_branch]}", opts_push)
118
+ end
119
+ else
120
+ repo.push('origin', branch, opts_push)
121
+ end
122
+ rescue Git::GitExecuteError => e
123
+ raise unless e.message.match?(/working (directory|tree) clean/)
124
+
125
+ puts "There were no changes in '#{@directory}'. Not committing."
126
+ return false
127
+ end
128
+
129
+ true
130
+ end
131
+
132
+ # Needed because of a bug in the git gem that lists ignored files as
133
+ # untracked under some circumstances
134
+ # https://github.com/schacon/ruby-git/issues/130
135
+ def untracked_unignored_files
136
+ ignore_path = "#{@directory}/.gitignore"
137
+ ignored = File.exist?(ignore_path) ? File.read(ignore_path).split : []
138
+ repo.status.untracked.keep_if { |f, _| ignored.none? { |i| File.fnmatch(i, f) } }
139
+ end
140
+
141
+ def show_changes(options)
142
+ checkout_branch(options[:branch])
143
+
144
+ puts 'Files changed:'
145
+ repo.diff('HEAD', '--').each do |diff|
146
+ puts diff.patch
147
+ end
148
+
149
+ puts 'Files added:'
150
+ untracked_unignored_files.each_key do |file|
151
+ puts file
152
+ end
153
+
154
+ puts "\n\n"
155
+ puts '--------------------------------'
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,57 @@
1
+ require 'modulesync'
2
+ require 'modulesync/repository'
3
+ require 'modulesync/util'
4
+
5
+ module ModuleSync
6
+ # Provide methods to retrieve source code attributes
7
+ class SourceCode
8
+ attr_reader :given_name
9
+ attr_reader :options
10
+
11
+ def initialize(given_name, options)
12
+ @options = Util.symbolize_keys(options || {})
13
+
14
+ @given_name = given_name
15
+
16
+ return unless given_name.include?('/')
17
+
18
+ @repository_name = given_name.split('/').last
19
+ @repository_namespace = given_name.split('/')[0...-1].join('/')
20
+ end
21
+
22
+ def repository
23
+ @repository ||= Repository.new directory: working_directory, remote: repository_remote
24
+ end
25
+
26
+ def repository_name
27
+ @repository_name ||= given_name
28
+ end
29
+
30
+ def repository_namespace
31
+ @repository_namespace ||= @options[:namespace] || ModuleSync.options[:namespace]
32
+ end
33
+
34
+ def repository_path
35
+ @repository_path ||= "#{repository_namespace}/#{repository_name}"
36
+ end
37
+
38
+ def repository_remote
39
+ @repository_remote ||= @options[:remote] || _repository_remote
40
+ end
41
+
42
+ def working_directory
43
+ @working_directory ||= File.join(ModuleSync.options[:project_root], repository_path)
44
+ end
45
+
46
+ def path(*parts)
47
+ File.join(working_directory, *parts)
48
+ end
49
+
50
+ private
51
+
52
+ def _repository_remote
53
+ git_base = ModuleSync.options[:git_base]
54
+ git_base.start_with?('file://') ? "#{git_base}#{repository_path}" : "#{git_base}#{repository_path}.git"
55
+ end
56
+ end
57
+ 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
data/modulesync.gemspec CHANGED
@@ -3,14 +3,14 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
3
 
4
4
  Gem::Specification.new do |spec|
5
5
  spec.name = 'modulesync'
6
- spec.version = '1.2.0'
6
+ spec.version = '2.1.0'
7
7
  spec.authors = ['Vox Pupuli']
8
8
  spec.email = ['voxpupuli@groups.io']
9
9
  spec.summary = 'Puppet Module Synchronizer'
10
10
  spec.description = 'Utility to synchronize common files across puppet modules in Github.'
11
11
  spec.homepage = 'http://github.com/voxpupuli/modulesync'
12
12
  spec.license = 'Apache-2.0'
13
- spec.required_ruby_version = '>= 2.0.0'
13
+ spec.required_ruby_version = '>= 2.5.0'
14
14
 
15
15
  spec.files = `git ls-files -z`.split("\x0")
16
16
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
@@ -23,9 +23,9 @@ Gem::Specification.new do |spec|
23
23
  spec.add_development_dependency 'rspec'
24
24
  spec.add_development_dependency 'rubocop', '~> 0.50.0'
25
25
 
26
- spec.add_runtime_dependency 'git', '~>1.3'
26
+ spec.add_runtime_dependency 'git', '~>1.7'
27
27
  spec.add_runtime_dependency 'gitlab', '~>4.0'
28
28
  spec.add_runtime_dependency 'octokit', '~>4.0'
29
- spec.add_runtime_dependency 'puppet-blacksmith', '~>3.0'
29
+ spec.add_runtime_dependency 'puppet-blacksmith', '>= 3.0', '< 7'
30
30
  spec.add_runtime_dependency 'thor'
31
31
  end
@@ -0,0 +1,14 @@
1
+ module ModuleSync
2
+ # Faker is a top-level module to keep global faker config
3
+ module Faker
4
+ def self.working_directory=(path)
5
+ @working_directory = path
6
+ end
7
+
8
+ def self.working_directory
9
+ raise 'Working directory must be set' if @working_directory.nil?
10
+ FileUtils.mkdir_p @working_directory
11
+ @working_directory
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,146 @@
1
+ require 'open3'
2
+
3
+ require_relative '../faker'
4
+
5
+ module ModuleSync
6
+ # Fake a remote git repository that holds a puppet module
7
+ #
8
+ # This module allows to fake a remote repositiory using:
9
+ # - a bare repo
10
+ # - a temporary cloned repo to operate on the remote before exposing to modulesync
11
+ #
12
+ # Note: This module needs to have working_directory sets before using it
13
+ module Faker
14
+ class PuppetModuleRemoteRepo
15
+ class CommandExecutionError < StandardError; end
16
+
17
+ attr_reader :name, :namespace
18
+
19
+ def initialize(name, namespace)
20
+ @name = name
21
+ @namespace = namespace
22
+ end
23
+
24
+ def populate
25
+ FileUtils.chdir(Faker.working_directory) do
26
+ run %W[git init --bare #{bare_repo_dir}]
27
+ run %W[git clone #{bare_repo_dir} #{tmp_repo_dir}]
28
+
29
+ module_short_name = name.split('-').last
30
+
31
+ FileUtils.chdir(tmp_repo_dir) do
32
+ metadata = {
33
+ name: "modulesync-#{module_short_name}",
34
+ version: '0.4.2',
35
+ author: 'ModuleSync team',
36
+ }
37
+
38
+ File.write 'metadata.json', metadata.to_json
39
+ run %w[git add metadata.json]
40
+ run %w[git commit --message] << 'Initial commit'
41
+ run %w[git push]
42
+ end
43
+ end
44
+ end
45
+
46
+ def read_only=(value)
47
+ mode = value ? '0444' : '0644'
48
+ FileUtils.chdir(bare_repo_dir) do
49
+ run %W[git config core.sharedRepository #{mode}]
50
+ end
51
+ end
52
+
53
+ def default_branch
54
+ FileUtils.chdir(bare_repo_dir) do
55
+ stdout = run %w[git symbolic-ref --short HEAD]
56
+ return stdout.chomp
57
+ end
58
+ end
59
+
60
+ def default_branch=(value)
61
+ FileUtils.chdir(bare_repo_dir) do
62
+ run %W[git branch -M #{default_branch} #{value}]
63
+ run %W[git symbolic-ref HEAD refs/heads/#{value}]
64
+ end
65
+ end
66
+
67
+ def read_file(filename, branch = nil)
68
+ branch ||= default_branch
69
+ FileUtils.chdir(bare_repo_dir) do
70
+ return run %W[git show #{branch}:#{filename}]
71
+ rescue CommandExecutionError
72
+ return nil
73
+ end
74
+ end
75
+
76
+ def add_file(filename, content, branch = nil)
77
+ branch ||= default_branch
78
+ FileUtils.chdir(tmp_repo_dir) do
79
+ run %W[git checkout #{branch}]
80
+ File.write filename, content
81
+ run %W[git add #{filename}]
82
+ run %w[git commit --message] << "Add file: '#{filename}'"
83
+ run %w[git push]
84
+ end
85
+ end
86
+
87
+ def commit_count_between(commit1, commit2)
88
+ FileUtils.chdir(bare_repo_dir) do
89
+ stdout = run %W[git rev-list --count #{commit1}..#{commit2}]
90
+ return Integer(stdout)
91
+ end
92
+ end
93
+
94
+ def commit_count_by(author, commit = nil)
95
+ FileUtils.chdir(bare_repo_dir) do
96
+ commit ||= '--all'
97
+ stdout = run %W[git rev-list #{commit} --author #{author} --count]
98
+ return Integer(stdout)
99
+ end
100
+ end
101
+
102
+ def tags
103
+ FileUtils.chdir(bare_repo_dir) do
104
+ return run %w{git tag --list}
105
+ end
106
+ end
107
+
108
+ def remote_url
109
+ "file://#{bare_repo_dir}"
110
+ end
111
+
112
+ def self.git_base
113
+ "file://#{Faker.working_directory}/bare/"
114
+ end
115
+
116
+ private
117
+
118
+ def tmp_repo_dir
119
+ File.join Faker.working_directory, 'tmp', namespace, name
120
+ end
121
+
122
+ def bare_repo_dir
123
+ File.join Faker.working_directory, 'bare', namespace, "#{name}.git"
124
+ end
125
+
126
+ GIT_ENV = {
127
+ 'GIT_AUTHOR_NAME' => 'Faker',
128
+ 'GIT_AUTHOR_EMAIL' => 'faker@example.com',
129
+ 'GIT_COMMITTER_NAME' => 'Faker',
130
+ 'GIT_COMMITTER_EMAIL' => 'faker@example.com',
131
+ }.freeze
132
+
133
+ def run(command)
134
+ stdout, stderr, status = Open3.capture3(GIT_ENV, *command)
135
+ return stdout if status.success?
136
+
137
+ warn "Command '#{command}' failed: #{status}"
138
+ warn ' STDOUT:'
139
+ warn stdout
140
+ warn ' STDERR:'
141
+ warn stderr
142
+ raise CommandExecutionError, "Command '#{command}' failed: #{status}"
143
+ end
144
+ end
145
+ end
146
+ end