diff --git a/.gitignore b/.gitignore index e6f19f20..81330d87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ -tmp/ -modules/ *.gem -Gemfile.lock .bundle/ +.ruby-version +.vscode/ +Gemfile.lock +bin/bundle +bin/rspec +modules/ +tmp/ +vendor/ diff --git a/README.md b/README.md index 8d7d2e00..13eee0e7 100644 --- a/README.md +++ b/README.md @@ -176,27 +176,29 @@ touching the modules, you can deactivate the hook. msync hook deactivate ``` -#### Submitting PRs to GitHub +#### Submitting PRs/MRs to GitHub or GitLab -You can have modulesync submit Pull Requests on GitHub automatically with the -`--pr` CLI option. +You can have modulesync submit Pull Requests on GitHub or Merge Requests on +GitLab automatically with the `--pr` CLI option. ``` msync update --pr ``` -You must set `GITHUB_TOKEN` in your environment for this to work. Other options -include: +You must set the `GITHUB_TOKEN` or `GITLAB_TOKEN` environment variable +for GitHub PRs or GitLab MRs to work. Additional options: -* Set the PR title with `--pr-title` or in `modulesync.yml` with the `pr_title` - attribute. -* Assign labels to the PR with `--pr-labels` or in `modulesync.yml` with the - `pr_labels` attribute. **NOTE:** `pr_labels` should be a list. When using - the `--pr-labels` CLI option, you should use a comma separated list. +* Set the PR/MR title with `--pr-title` or in `modulesync.yml` with the + `pr_title` attribute. +* Assign labels to the PR/MR with `--pr-labels` or in `modulesync.yml` with + the `pr_labels` attribute. **NOTE:** `pr_labels` should be a list. When + using the `--pr-labels` CLI option, you should use a comma separated list. -You can optionally set the `GITHUB_BASE_URL` environment variable to use GitHub -Enterprise. This is passed to Octokit's [`api_endpoint`](https://github.com/octokit/octokit.rb#interacting-with-the-githubcom-apis-in-github-enterprise) -configuration option. +For GitHub Enterprise and self-hosted GitLab instances you need to set the +`GITHUB_BASE_URL` or `GITLAB_BASE_URL` environment variable in addition. +More details for GitHub: + +* Octokit [`api_endpoint`](https://github.com/octokit/octokit.rb#interacting-with-the-githubcom-apis-in-github-enterprise) ### Using Forks and Non-master branches @@ -258,8 +260,8 @@ Available parameters for modulesync.yml * `remote_branch` : Remote branch to push to (Default: Same value as branch) * `message` : Commit message to apply to updated modules. * `pre_commit_script` : A script to be run before commiting (e.g. 'contrib/myfooscript.sh') -* `pr_title` : The title to use when submitting PRs to GitHub. -* `pr_labels` : A list of labels to assign PRs created on GitHub. +* `pr_title` : The title to use when submitting PRs/MRs to GitHub or GitLab. +* `pr_labels` : A list of labels to assign PRs/MRs created on GitHub or GitLab. ##### Example diff --git a/lib/modulesync.rb b/lib/modulesync.rb index 33478266..e8e55dd4 100644 --- a/lib/modulesync.rb +++ b/lib/modulesync.rb @@ -1,5 +1,4 @@ require 'fileutils' -require 'octokit' require 'pathname' require 'modulesync/cli' require 'modulesync/constants' @@ -10,12 +9,6 @@ require 'modulesync/util' require 'monkey_patches' -GITHUB_TOKEN = ENV.fetch('GITHUB_TOKEN', '') - -Octokit.configure do |c| - c.api_endpoint = ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com') -end - module ModuleSync # rubocop:disable Metrics/ModuleLength include Constants @@ -136,48 +129,22 @@ def self.manage_module(puppet_module, module_files, module_options, defaults, op if options[:noop] Git.update_noop(git_repo, options) elsif !options[:offline] - # Git.update() returns a boolean: true if files were pushed, false if not. pushed = Git.update(git_repo, files_to_manage, options) - return nil unless pushed && options[:pr] - - manage_pr(namespace, module_name, options) - end - end - - def self.manage_pr(namespace, module_name, options) - if options[:pr] && GITHUB_TOKEN.empty? - $stderr.puts 'Environment variable GITHUB_TOKEN must be set to use --pr!' - raise unless options[:skip_broken] - end - - # We only do GitHub PR work if the GITHUB_TOKEN variable is set in the environment. - repo_path = File.join(namespace, module_name) - github = Octokit::Client.new(:access_token => GITHUB_TOKEN) - - # Skip creating the PR if it exists already. - head = "#{namespace}:#{options[:branch]}" - pull_requests = github.pull_requests(repo_path, :state => 'open', :base => 'master', :head => head) - if pull_requests.empty? - pr = github.create_pull_request(repo_path, 'master', options[:branch], options[:pr_title], options[:message]) - $stdout.puts "Submitted PR '#{options[:pr_title]}' to #{repo_path} - merges #{options[:branch]} into master" - else - $stdout.puts "Skipped! #{pull_requests.length} PRs found for branch #{options[:branch]}" + pushed && options[:pr] && @pr.manage(namespace, module_name, options) end - - # PR labels can either be a list in the YAML file or they can pass in a comma - # separated list via the command line argument. - pr_labels = Util.parse_list(options[:pr_labels]) - - # We only assign labels to the PR if we've discovered a list > 1. The labels MUST - # already exist. We DO NOT create missing labels. - return if pr_labels.empty? - $stdout.puts "Attaching the following labels to PR #{pr['number']}: #{pr_labels.join(', ')}" - github.add_labels_to_an_issue(repo_path, pr['number'], pr_labels) end def self.update(options) options = config_defaults.merge(options) defaults = Util.parse_config(File.join(options[:configs], CONF_FILE)) + if options[:pr] + unless options[:branch] + $stderr.puts 'A branch must be specified with --branch to use --pr!' + raise + end + + @pr = create_pr_manager if options[:pr] + end local_template_dir = File.join(options[:configs], MODULE_FILES_DIR) local_files = find_template_files(local_template_dir) @@ -201,4 +168,20 @@ def self.update(options) end exit 1 if errors && options[:fail_on_warnings] end + + def self.create_pr_manager + github_token = ENV.fetch('GITHUB_TOKEN', '') + gitlab_token = ENV.fetch('GITLAB_TOKEN', '') + + if !github_token.empty? + require 'modulesync/pr/github' + ModuleSync::PR::GitHub.new(github_token, ENV.fetch('GITHUB_BASE_URL', 'https://api.github.com')) + elsif !gitlab_token.empty? + require 'modulesync/pr/github' + ModuleSync::PR::GitLab.new(gitlab_token, ENV.fetch('GITLAB_BASE_URL', 'https://gitlab.com/api/v4')) + else + $stderr.puts 'Environment variables GITHUB_TOKEN or GITLAB_TOKEN must be set to use --pr!' + raise + end + end end diff --git a/lib/modulesync/cli.rb b/lib/modulesync/cli.rb index 2d482a44..fe8d53b1 100644 --- a/lib/modulesync/cli.rb +++ b/lib/modulesync/cli.rb @@ -90,14 +90,14 @@ class Base < Thor :default => false option :pr, :type => :boolean, - :desc => 'Submit GitHub PR', + :desc => 'Submit pull/merge request', :default => false option :pr_title, - :desc => 'Title of GitHub PR', + :desc => 'Title of pull/merge request', :default => CLI.defaults[:pr_title] || 'Update to module template files' option :pr_labels, :type => :array, - :desc => 'Labels to add to the GitHub PR', + :desc => 'Labels to add to the pull/merge request', :default => CLI.defaults[:pr_labels] || [] option :offline, :type => :boolean, diff --git a/lib/modulesync/pr/github.rb b/lib/modulesync/pr/github.rb new file mode 100644 index 00000000..f352162a --- /dev/null +++ b/lib/modulesync/pr/github.rb @@ -0,0 +1,41 @@ +require 'octokit' +require 'modulesync/util' + +module ModuleSync + module PR + # GitHub creates and manages pull requests on github.com or GitHub + # Enterprise installations. + class GitHub + def initialize(token, endpoint) + Octokit.configure do |c| + c.api_endpoint = endpoint + end + @api = Octokit::Client.new(:access_token => token) + end + + def manage(namespace, module_name, options) + repo_path = File.join(namespace, module_name) + head = "#{namespace}:#{options[:branch]}" + + pull_requests = @api.pull_requests(repo_path, :state => 'open', :base => 'master', :head => head) + if pull_requests.empty? + pr = @api.create_pull_request(repo_path, 'master', options[:branch], options[:pr_title], options[:message]) + $stdout.puts "Submitted PR '#{options[:pr_title]}' to #{repo_path} - merges #{options[:branch]} into master" + else + # Skip creating the PR if it exists already. + $stdout.puts "Skipped! #{pull_requests.length} PRs found for branch #{options[:branch]}" + end + + # PR labels can either be a list in the YAML file or they can pass in a comma + # separated list via the command line argument. + pr_labels = ModuleSync::Util.parse_list(options[:pr_labels]) + + # We only assign labels to the PR if we've discovered a list > 1. The labels MUST + # already exist. We DO NOT create missing labels. + return if pr_labels.empty? + $stdout.puts "Attaching the following labels to PR #{pr['number']}: #{pr_labels.join(', ')}" + @api.add_labels_to_an_issue(repo_path, pr['number'], pr_labels) + end + end + end +end diff --git a/lib/modulesync/pr/gitlab.rb b/lib/modulesync/pr/gitlab.rb new file mode 100644 index 00000000..68ebb335 --- /dev/null +++ b/lib/modulesync/pr/gitlab.rb @@ -0,0 +1,40 @@ +require 'gitlab' +require 'modulesync/util' + +module ModuleSync + module PR + # GitLab creates and manages merge requests on gitlab.com or private GitLab + # installations. + class GitLab + def initialize(token, endpoint) + @api = Gitlab::Client.new( + :endpoint => endpoint, + :private_token => token + ) + end + + def manage(namespace, module_name, options) + repo_path = File.join(namespace, module_name) + + head = "#{namespace}:#{options[:branch]}" + merge_requests = @api.merge_requests(repo_path, + :state => 'opened', + :source_branch => head, + :target_branch => 'master') + if merge_requests.empty? + mr_labels = ModuleSync::Util.parse_list(options[:pr_labels]) + mr = @api.create_merge_request(repo_path, options[:pr_title], + :source_branch => options[:branch], + :target_branch => 'master', + :labels => mr_labels) + $stdout.puts "Submitted MR '#{options[:pr_title]}' to #{repo_path} - merges #{options[:branch]} into master" + return if mr_labels.empty? + $stdout.puts "Attached the following labels to MR #{mr.iid}: #{mr_labels.join(', ')}" + else + # Skip creating the MR if it exists already. + $stdout.puts "Skipped! #{merge_requests.length} MRs found for branch #{options[:branch]}" + end + end + end + end +end diff --git a/modulesync.gemspec b/modulesync.gemspec index 293a52d3..16da9be6 100644 --- a/modulesync.gemspec +++ b/modulesync.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rubocop', '~> 0.50.0' spec.add_runtime_dependency 'git', '~>1.3' + spec.add_runtime_dependency 'gitlab', '~>4.0' spec.add_runtime_dependency 'octokit', '~>4.0' spec.add_runtime_dependency 'puppet-blacksmith', '~>3.0' spec.add_runtime_dependency 'thor' diff --git a/spec/unit/modulesync/pr/github_spec.rb b/spec/unit/modulesync/pr/github_spec.rb new file mode 100644 index 00000000..830c4b05 --- /dev/null +++ b/spec/unit/modulesync/pr/github_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' +require 'modulesync/pr/github' + +describe ModuleSync::PR::GitHub do + context '::manage' do + before(:each) do + @git_repo = 'test/modulesync' + @namespace, @repo_name = @git_repo.split('/') + @options = { + :pr => true, + :pr_title => 'Test PR is submitted', + :branch => 'test', + :message => 'Hello world', + :pr_auto_merge => false, + } + + @client = double() + allow(Octokit::Client).to receive(:new).and_return(@client) + @it = ModuleSync::PR::GitHub.new('test', 'https://api.github.com') + end + + it 'submits PR when --pr is set' do + allow(@client).to receive(:pull_requests).with(@git_repo, :state => 'open', :base => 'master', :head => "#{@namespace}:#{@options[:branch]}").and_return([]) + expect(@client).to receive(:create_pull_request).with(@git_repo, 'master', @options[:branch], @options[:pr_title], @options[:message]).and_return({"html_url" => "http://example.com/pulls/22"}) + expect { @it.manage(@namespace, @repo_name, @options) }.to output(/Submitted PR/).to_stdout + end + + it 'skips submitting PR if one has already been issued' do + pr = { + "title" => "Test title", + "html_url" => "https://example.com/pulls/44", + "number" => "44" + } + + expect(@client).to receive(:pull_requests).with(@git_repo, :state => 'open', :base => 'master', :head => "#{@namespace}:#{@options[:branch]}").and_return([pr]) + expect { @it.manage(@namespace, @repo_name, @options) }.to output(/Skipped! 1 PRs found for branch test/).to_stdout + end + + it 'adds labels to PR when --pr-labels is set' do + @options[:pr_labels] = "HELLO,WORLD" + + allow(@client).to receive(:create_pull_request).and_return({"html_url" => "http://example.com/pulls/22", "number" => "44"}) + allow(@client).to receive(:pull_requests).with(@git_repo, :state => 'open', :base => 'master', :head => "#{@namespace}:#{@options[:branch]}").and_return([]) + + expect(@client).to receive(:add_labels_to_an_issue).with(@git_repo, "44", ["HELLO", "WORLD"]) + expect { @it.manage(@namespace, @repo_name, @options) }.to output(/Attaching the following labels to PR 44: HELLO, WORLD/).to_stdout + end + end +end diff --git a/spec/unit/modulesync/pr/gitlab_spec.rb b/spec/unit/modulesync/pr/gitlab_spec.rb new file mode 100644 index 00000000..360a773b --- /dev/null +++ b/spec/unit/modulesync/pr/gitlab_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' +require 'modulesync/pr/gitlab' + +describe ModuleSync::PR::GitLab do + context '::manage' do + before(:each) do + @git_repo = 'test/modulesync' + @namespace, @repo_name = @git_repo.split('/') + @options = { + :pr => true, + :pr_title => 'Test PR is submitted', + :branch => 'test', + :message => 'Hello world', + :pr_auto_merge => false, + } + + @client = double() + allow(Gitlab::Client).to receive(:new).and_return(@client) + @it = ModuleSync::PR::GitLab.new('test', 'https://gitlab.com/api/v4') + end + + it 'submits MR when --pr is set' do + allow(@client).to receive(:merge_requests) + .with(@git_repo, + :state => 'opened', + :source_branch => "#{@namespace}:#{@options[:branch]}", + :target_branch => 'master', + ).and_return([]) + + expect(@client).to receive(:create_merge_request) + .with(@git_repo, + @options[:pr_title], + :labels => [], + :source_branch => @options[:branch], + :target_branch => 'master', + ).and_return({"html_url" => "http://example.com/pulls/22"}) + + expect { @it.manage(@namespace, @repo_name, @options) }.to output(/Submitted MR/).to_stdout + end + + it 'skips submitting MR if one has already been issued' do + mr = { + "title" => "Test title", + "html_url" => "https://example.com/pulls/44", + "iid" => "44" + } + + expect(@client).to receive(:merge_requests) + .with(@git_repo, + :state => 'opened', + :source_branch => "#{@namespace}:#{@options[:branch]}", + :target_branch => 'master', + ).and_return([mr]) + + expect { @it.manage(@namespace, @repo_name, @options) }.to output(/Skipped! 1 MRs found for branch test/).to_stdout + end + + it 'adds labels to MR when --pr-labels is set' do + @options[:pr_labels] = "HELLO,WORLD" + mr = double() + allow(mr).to receive(:iid).and_return("42") + + expect(@client).to receive(:create_merge_request) + .with(@git_repo, + @options[:pr_title], + :labels => ["HELLO", "WORLD"], + :source_branch => @options[:branch], + :target_branch => 'master', + ).and_return(mr) + + allow(@client).to receive(:merge_requests) + .with(@git_repo, + :state => 'opened', + :source_branch => "#{@namespace}:#{@options[:branch]}", + :target_branch => 'master', + ).and_return([]) + + expect { @it.manage(@namespace, @repo_name, @options) }.to output(/Attached the following labels to MR 42: HELLO, WORLD/).to_stdout + end + end +end diff --git a/spec/unit/modulesync_spec.rb b/spec/unit/modulesync_spec.rb index 0b72d104..8197adff 100644 --- a/spec/unit/modulesync_spec.rb +++ b/spec/unit/modulesync_spec.rb @@ -12,58 +12,11 @@ end end - context '::manage_pr' do - before(:each) do - stub_const('GITHUB_TOKEN', 'test') - @git_repo = 'test/modulesync' - @namespace, @repo_name = @git_repo.split('/') - @options = { - :pr => true, - :pr_title => 'Test PR is submitted', - :branch => 'test', - :message => 'Hello world', - :pr_auto_merge => false, - } - - @client = double() - end - - it 'rasies an error when GITHUB_TOKEN not set for PRs' do - stub_const('GITHUB_TOKEN', '') - options = {:pr => true, :skip_broken => false} - - expect { ModuleSync.manage_pr(@namespace, @repo_name, options) }.to raise_error(RuntimeError).and output(/GITHUB_TOKEN/).to_stderr - end - - it 'submits PR when --pr is set' do - allow(Octokit::Client).to receive(:new).and_return(@client) - allow(@client).to receive(:pull_requests).with(@git_repo, :state => 'open', :base => 'master', :head => "#{@namespace}:#{@options[:branch]}").and_return([]) - expect(@client).to receive(:create_pull_request).with(@git_repo, 'master', @options[:branch], @options[:pr_title], @options[:message]).and_return({"html_url" => "http://example.com/pulls/22"}) - expect { ModuleSync.manage_pr(@namespace, @repo_name, @options) }.to output(/Submitted PR/).to_stdout - end - - it 'skips submitting PR if one has already been issued' do - allow(Octokit::Client).to receive(:new).and_return(@client) - - pr = { - "title" => "Test title", - "html_url" => "https://example.com/pulls/44", - "number" => "44" - } - - expect(@client).to receive(:pull_requests).with(@git_repo, :state => 'open', :base => 'master', :head => "#{@namespace}:#{@options[:branch]}").and_return([pr]) - expect { ModuleSync.manage_pr(@namespace, @repo_name, @options) }.to output(/Skipped! 1 PRs found for branch test/).to_stdout - end - - it 'adds labels to PR when --pr-labels is set' do - @options[:pr_labels] = "HELLO,WORLD" - - allow(Octokit::Client).to receive(:new).and_return(@client) - allow(@client).to receive(:create_pull_request).and_return({"html_url" => "http://example.com/pulls/22", "number" => "44"}) - allow(@client).to receive(:pull_requests).with(@git_repo, :state => 'open', :base => 'master', :head => "#{@namespace}:#{@options[:branch]}").and_return([]) - - expect(@client).to receive(:add_labels_to_an_issue).with(@git_repo, "44", ["HELLO", "WORLD"]) - expect { ModuleSync.manage_pr(@namespace, @repo_name, @options) }.to output(/Attaching the following labels to PR 44: HELLO, WORLD/).to_stdout + context '::create_pr_manager' do + describe "Raise Error" do + it 'raises an error when neither GITHUB_TOKEN nor GITLAB_TOKEN are set for PRs' do + expect { ModuleSync.create_pr_manager() }.to raise_error(RuntimeError).and output(/GITHUB_TOKEN/).to_stderr + end end end end