git-pr-release 0.8.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c36ef54d09bfdcf555b32be02292677376092fe20627840c02e07afa4efe8ca
4
- data.tar.gz: c349820f306a907532796832edeb8de6442b97aca3bff4f772c5282840648133
3
+ metadata.gz: 9184a8468614334e1e8b4d4a61e56405c033afae2199e7288cfee2154ec6c444
4
+ data.tar.gz: 76075368c8268080461c713b2fa1621a6cf714fd5ea95c39763278f90d2479d9
5
5
  SHA512:
6
- metadata.gz: 59acdd4d880b9e16b4a78e28d8a6ed7fff39c4735e2a86895e83349242dcdff622a3a21ffc7204681bdf791895ec50cdea4de8b26519fdc34a48b327697f31ad
7
- data.tar.gz: 291188d74a7d713d2e4e48bce8f04c51eebcdd716449f4e9196c43d7340d1a311bcc980e59b9e4cc621846254289134a4f5a023d1da8eb1bdc36e2f0c8021d6a
6
+ metadata.gz: 84befebc97d17f0ad0a85347fa489ccfde2713ac77f3a0455f8f576c3fe191f7b566137a8efbeb369cf1e65b171d15afbdf93a89f8e1452c54556f042bd42e65
7
+ data.tar.gz: 0d1937de82dd289dad1c323f9e6f35c0503a638dba37b235691ae79a5a75b079719213964a74135a782a630dc70a40fdb5f73cfa8940dd764c72f555f97a7eb5
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "git/pr/release"
4
+
5
+ Git::Pr::Release::CLI.start
@@ -4,7 +4,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "git-pr-release"
7
- spec.version = '0.8.0'
7
+ spec.version = '1.0.0'
8
8
  spec.authors = ["motemen"]
9
9
  spec.email = ["motemen@gmail.com"]
10
10
  spec.summary = 'Creates a release pull request'
@@ -12,7 +12,8 @@ Gem::Specification.new do |spec|
12
12
  spec.homepage = 'https://github.com/motemen/git-pr-release'
13
13
 
14
14
  spec.files = `git ls-files`.split($/)
15
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
15
+ spec.bindir = "exe"
16
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
16
17
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
17
18
  spec.require_paths = ["lib"]
18
19
 
@@ -0,0 +1,11 @@
1
+ require "git/pr/release/util"
2
+ require "git/pr/release/pull_request"
3
+ require "git/pr/release/dummy_pull_request"
4
+ require "git/pr/release/cli"
5
+
6
+ module Git
7
+ module Pr
8
+ module Release
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,159 @@
1
+ require 'octokit'
2
+ require 'optparse'
3
+
4
+ module Git
5
+ module Pr
6
+ module Release
7
+ class CLI
8
+ include Git::Pr::Release::Util
9
+ def self.start
10
+ host, repository, scheme = host_and_repository_and_scheme
11
+
12
+ if host
13
+ # GitHub:Enterprise
14
+ OpenSSL::SSL.const_set :VERIFY_PEER, OpenSSL::SSL::VERIFY_NONE # XXX
15
+
16
+ Octokit.configure do |c|
17
+ c.api_endpoint = "#{scheme}://#{host}/api/v3"
18
+ c.web_endpoint = "#{scheme}://#{host}/"
19
+ end
20
+ end
21
+
22
+ OptionParser.new do |opts|
23
+ opts.on('-n', '--dry-run', 'Do not create/update a PR. Just prints out') do |v|
24
+ @dry_run = v
25
+ end
26
+ opts.on('--json', 'Show data of target PRs in JSON format') do |v|
27
+ @json = v
28
+ end
29
+ opts.on('--no-fetch', 'Do not fetch from remote repo before determining target PRs (CI friendly)') do |v|
30
+ @no_fetch = v
31
+ end
32
+ end.parse!
33
+
34
+ ### Set up configuration
35
+
36
+ production_branch = ENV.fetch('GIT_PR_RELEASE_BRANCH_PRODUCTION') { git_config('branch.production') } || 'master'
37
+ staging_branch = ENV.fetch('GIT_PR_RELEASE_BRANCH_STAGING') { git_config('branch.staging') } || 'staging'
38
+
39
+ say "Repository: #{repository}", :debug
40
+ say "Production branch: #{production_branch}", :debug
41
+ say "Staging branch: #{staging_branch}", :debug
42
+
43
+ client = Octokit::Client.new :access_token => obtain_token!
44
+
45
+ git :remote, 'update', 'origin' unless @no_fetch
46
+
47
+ ### Fetch merged PRs
48
+
49
+ merged_feature_head_sha1s = git(
50
+ :log, '--merges', '--pretty=format:%P', "origin/#{production_branch}..origin/#{staging_branch}"
51
+ ).map do |line|
52
+ main_sha1, feature_sha1 = line.chomp.split /\s+/
53
+ feature_sha1
54
+ end
55
+
56
+ merged_pull_request_numbers = git('ls-remote', 'origin', 'refs/pull/*/head').map do |line|
57
+ sha1, ref = line.chomp.split /\s+/
58
+
59
+ if merged_feature_head_sha1s.include? sha1
60
+ if %r<^refs/pull/(\d+)/head$>.match ref
61
+ pr_number = $1.to_i
62
+
63
+ if git('merge-base', sha1, "origin/#{production_branch}").first.chomp == sha1
64
+ say "##{pr_number} (#{sha1}) is already merged into #{production_branch}", :debug
65
+ else
66
+ pr_number
67
+ end
68
+ else
69
+ say "Bad pull request head ref format: #{ref}", :warn
70
+ nil
71
+ end
72
+ end
73
+ end.compact
74
+
75
+ if merged_pull_request_numbers.empty?
76
+ say 'No pull requests to be released', :error
77
+ exit 1
78
+ end
79
+
80
+ merged_prs = merged_pull_request_numbers.map do |nr|
81
+ pr = client.pull_request repository, nr
82
+ say "To be released: ##{pr.number} #{pr.title}", :notice
83
+ pr
84
+ end
85
+
86
+ ### Create a release PR
87
+
88
+ say 'Searching for existing release pull requests...', :info
89
+ found_release_pr = client.pull_requests(repository).find do |pr|
90
+ pr.head.ref == staging_branch && pr.base.ref == production_branch
91
+ end
92
+ create_mode = found_release_pr.nil?
93
+
94
+ # Fetch changed files of a release PR
95
+ changed_files = pull_request_files(client, found_release_pr)
96
+
97
+ if @dry_run
98
+ pr_title, new_body = build_pr_title_and_body found_release_pr, merged_prs, changed_files
99
+ pr_body = create_mode ? new_body : merge_pr_body(found_release_pr.body, new_body)
100
+
101
+ say 'Dry-run. Not updating PR', :info
102
+ say pr_title, :notice
103
+ say pr_body, :notice
104
+ dump_result_as_json( found_release_pr, merged_prs, changed_files ) if @json
105
+ exit 0
106
+ end
107
+
108
+ pr_title, pr_body = nil, nil
109
+ release_pr = nil
110
+
111
+ if create_mode
112
+ created_pr = client.create_pull_request(
113
+ repository, production_branch, staging_branch, 'Preparing release pull request...', ''
114
+ )
115
+ unless created_pr
116
+ say 'Failed to create a new pull request', :error
117
+ exit 2
118
+ end
119
+ changed_files = pull_request_files(client, created_pr) # Refetch changed files from created_pr
120
+ pr_title, pr_body = build_pr_title_and_body created_pr, merged_prs, changed_files
121
+ release_pr = created_pr
122
+ else
123
+ pr_title, new_body = build_pr_title_and_body found_release_pr, merged_prs, changed_files
124
+ pr_body = merge_pr_body(found_release_pr.body, new_body)
125
+ release_pr = found_release_pr
126
+ end
127
+
128
+ say 'Pull request body:', :debug
129
+ say pr_body, :debug
130
+
131
+ updated_pull_request = client.update_pull_request(
132
+ repository, release_pr.number, :title => pr_title, :body => pr_body
133
+ )
134
+
135
+ unless updated_pull_request
136
+ say 'Failed to update a pull request', :error
137
+ exit 3
138
+ end
139
+
140
+ labels = ENV.fetch('GIT_PR_RELEASE_LABELS') { git_config('labels') }
141
+ if not labels.nil? and not labels.empty?
142
+ labels = labels.split(/\s*,\s*/)
143
+ labeled_pull_request = client.add_labels_to_an_issue(
144
+ repository, release_pr.number, labels
145
+ )
146
+
147
+ unless labeled_pull_request
148
+ say 'Failed to add labels to a pull request', :error
149
+ exit 4
150
+ end
151
+ end
152
+
153
+ say "#{create_mode ? 'Created' : 'Updated'} pull request: #{updated_pull_request.rels[:html].href}", :notice
154
+ dump_result_as_json( release_pr, merged_prs, changed_files ) if @json
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,23 @@
1
+ module Git
2
+ module Pr
3
+ module Release
4
+ class DummyPullRequest
5
+ def initialize
6
+ # nop
7
+ end
8
+
9
+ def to_checklist_item
10
+ "- [ ] #??? THIS IS DUMMY PULL REQUEST"
11
+ end
12
+
13
+ def html_link
14
+ 'http://github.com/DUMMY/DUMMY/issues/?'
15
+ end
16
+
17
+ def to_hash
18
+ { :data => {} }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ module Git
2
+ module Pr
3
+ module Release
4
+ class PullRequest
5
+ include Git::Pr::Release::Util
6
+ extend Git::Pr::Release::Util
7
+ attr_reader :pr
8
+
9
+ def initialize(pr)
10
+ @pr = pr
11
+ end
12
+
13
+ def to_checklist_item
14
+ "- [ ] ##{pr.number} #{pr.title}" + mention
15
+ end
16
+
17
+ def html_link
18
+ pr.rels[:html].href
19
+ end
20
+
21
+ def to_hash
22
+ { :data => @pr.to_hash }
23
+ end
24
+
25
+ def mention
26
+ mention = case PullRequest.mention_type
27
+ when 'author'
28
+ pr.user ? "@#{pr.user.login}" : nil
29
+ else
30
+ pr.assignee ? "@#{pr.assignee.login}" : pr.user ? "@#{pr.user.login}" : nil
31
+ end
32
+
33
+ mention ? " #{mention}" : ""
34
+ end
35
+
36
+ def self.mention_type
37
+ @mention_type ||= (git_config('mention') || 'default')
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,209 @@
1
+ require 'erb'
2
+ require 'uri'
3
+ require 'open3'
4
+ require 'json'
5
+ require 'colorize'
6
+ require 'diff/lcs'
7
+
8
+ module Git
9
+ module Pr
10
+ module Release
11
+ module Util
12
+ def host_and_repository_and_scheme
13
+ @host_and_repository_and_scheme ||= begin
14
+ remote = git(:config, 'remote.origin.url').first.chomp
15
+ unless %r(^\w+://) === remote
16
+ remote = "ssh://#{remote.sub(':', '/')}"
17
+ end
18
+
19
+ remote_url = URI.parse(remote)
20
+ repository = remote_url.path.sub(%r(^/), '').sub(/\.git$/, '')
21
+
22
+ host = remote_url.host == 'github.com' ? nil : remote_url.host
23
+ [ host, repository, remote_url.scheme === 'http' ? 'http' : 'https' ]
24
+ end
25
+ end
26
+
27
+ def say(message, level)
28
+ color = case level
29
+ when :trace
30
+ return unless ENV['DEBUG']
31
+ nil
32
+ when :debug
33
+ return unless ENV['DEBUG']
34
+ :blue
35
+ when :info
36
+ :green
37
+ when :notice
38
+ :yellow
39
+ when :warn
40
+ :magenta
41
+ when :error
42
+ :red
43
+ end
44
+
45
+ STDERR.puts message.colorize(color)
46
+ end
47
+
48
+ def git(*command)
49
+ command = [ 'git', *command.map(&:to_s) ]
50
+ say "Executing `#{command.join(' ')}`", :trace
51
+ out, status = Open3.capture2(*command)
52
+ unless status.success?
53
+ raise "Executing `#{command.join(' ')}` failed: #{status}"
54
+ end
55
+ out.each_line
56
+ end
57
+
58
+ def git_config(key)
59
+ host, _ = host_and_repository_and_scheme()
60
+
61
+ plain_key = [ 'pr-release', key ].join('.')
62
+ host_aware_key = [ 'pr-release', host, key ].compact.join('.')
63
+
64
+ begin
65
+ git(:config, '-f', '.git-pr-release', plain_key).first.chomp
66
+ rescue
67
+ git(:config, host_aware_key).first.chomp rescue nil
68
+ end
69
+ end
70
+
71
+ def git_config_set(key, value)
72
+ host, _ = host_and_repository_and_scheme()
73
+ host_aware_key = [ 'pr-release', host, key ].compact.join('.')
74
+
75
+ git :config, '--global', host_aware_key, value
76
+ end
77
+
78
+ # First line will be the title of the PR
79
+ DEFAULT_PR_TEMPLATE = <<ERB
80
+ Release <%= Time.now %>
81
+ <% pull_requests.each do |pr| -%>
82
+ <%= pr.to_checklist_item %>
83
+ <% end -%>
84
+ ERB
85
+
86
+ def build_pr_title_and_body(release_pr, merged_prs, changed_files)
87
+ release_pull_request = target_pull_request = release_pr ? PullRequest.new(release_pr) : DummyPullRequest.new
88
+ merged_pull_requests = pull_requests = merged_prs.map { |pr| PullRequest.new(pr) }
89
+
90
+ template = DEFAULT_PR_TEMPLATE
91
+
92
+ if path = ENV.fetch('GIT_PR_RELEASE_TEMPLATE') { git_config('template') }
93
+ template_path = File.join(git('rev-parse', '--show-toplevel').first.chomp, path)
94
+ template = File.read(template_path)
95
+ end
96
+
97
+ erb = ERB.new template, nil, '-'
98
+ content = erb.result binding
99
+ content.split(/\n/, 2)
100
+ end
101
+
102
+ def dump_result_as_json(release_pr, merged_prs, changed_files)
103
+ puts( {
104
+ :release_pull_request => (release_pr ? PullRequest.new(release_pr) : DummyPullRequest.new).to_hash,
105
+ :merged_pull_requests => merged_prs.map { |pr| PullRequest.new(pr).to_hash },
106
+ :changed_files => changed_files.map { |file| file.to_hash }
107
+ }.to_json )
108
+ end
109
+
110
+ def merge_pr_body(old_body, new_body)
111
+ # Try to take over checklist statuses
112
+ pr_body_lines = []
113
+
114
+ check_status = {}
115
+ old_body.split(/\r?\n/).each { |line|
116
+ line.match(/^- \[(?<check_value>[ x])\] #(?<issue_number>\d+)/) { |m|
117
+ say "Found pull-request checkbox \##{m[:issue_number]} is #{m[:check_value]}.", :trace
118
+ check_status[m[:issue_number]] = m[:check_value]
119
+ }
120
+ }
121
+ old_body_unchecked = old_body.gsub /^- \[[ x]\] \#(\d+)/, '- [ ] #\1'
122
+
123
+ Diff::LCS.traverse_balanced(old_body_unchecked.split(/\r?\n/), new_body.split(/\r?\n/)) do |event|
124
+ say "diff: #{event.inspect}", :trace
125
+ action, old, new = *event
126
+ old_nr, old_line = *old
127
+ new_nr, new_line = *new
128
+
129
+ case action
130
+ when '=', '+'
131
+ say "Use line as is: #{new_line}", :trace
132
+ pr_body_lines << new_line
133
+ when '-'
134
+ say "Use old line: #{old_line}", :trace
135
+ pr_body_lines << old_line
136
+ when '!'
137
+ if [ old_line, new_line ].all? { |line| /^- \[ \]/ === line }
138
+ say "Found checklist diff; use old one: #{old_line}", :trace
139
+ pr_body_lines << old_line
140
+ else
141
+ # not a checklist diff, use both line
142
+ say "Use line as is: #{old_line}", :trace
143
+ pr_body_lines << old_line
144
+
145
+ say "Use line as is: #{new_line}", :trace
146
+ pr_body_lines << new_line
147
+ end
148
+ else
149
+ say "Unknown diff event: #{event}", :warn
150
+ end
151
+ end
152
+
153
+ merged_body = pr_body_lines.join("\n")
154
+ check_status.each { |issue_number, check_value|
155
+ say "Update pull-request checkbox \##{issue_number} to #{check_value}.", :trace
156
+ merged_body.gsub! /^- \[ \] \##{issue_number}/, "- [#{check_value}] \##{issue_number}"
157
+ }
158
+
159
+ merged_body
160
+ end
161
+
162
+ def obtain_token!
163
+ token = ENV.fetch('GIT_PR_RELEASE_TOKEN') { git_config('token') }
164
+
165
+ unless token
166
+ require 'highline/import'
167
+ STDERR.puts 'Could not obtain GitHub API token.'
168
+ STDERR.puts 'Trying to generate token...'
169
+
170
+ username = ask('username? ') { |q| q.default = ENV['USER'] }
171
+ password = ask('password? (not saved) ') { |q| q.echo = '*' }
172
+
173
+ temporary_client = Octokit::Client.new :login => username, :password => password
174
+
175
+ auth = request_authorization(temporary_client, nil)
176
+
177
+ token = auth.token
178
+ git_config_set 'token', token
179
+ end
180
+
181
+ token
182
+ end
183
+
184
+ def request_authorization(client, two_factor_code)
185
+ params = { :scopes => [ 'public_repo', 'repo' ], :note => 'git-pr-release' }
186
+ params[:headers] = { "X-GitHub-OTP" => two_factor_code} if two_factor_code
187
+
188
+ auth = nil
189
+ begin
190
+ auth = client.create_authorization(params)
191
+ rescue Octokit::OneTimePasswordRequired
192
+ two_factor_code = ask('two-factor authentication code? ')
193
+ auth = request_authorization(client, two_factor_code)
194
+ end
195
+
196
+ auth
197
+ end
198
+
199
+ # Fetch PR files of specified pull_request
200
+ def pull_request_files(client, pull_request)
201
+ return [] if pull_request.nil?
202
+
203
+ host, repository, scheme = host_and_repository_and_scheme
204
+ return client.pull_request_files repository, pull_request.number
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -1,6 +1,5 @@
1
- load File.expand_path("../bin/git-pr-release", __dir__)
2
-
3
- RSpec.describe "git-pr-release" do
1
+ RSpec.describe Git::Pr::Release do
2
+ include Git::Pr::Release::Util
4
3
  before do
5
4
  Timecop.freeze(Time.parse("2019-02-20 22:58:35"))
6
5
 
@@ -71,7 +70,7 @@ RSpec.describe "git-pr-release" do
71
70
 
72
71
  expect(parsed_output.keys).to eq %w[release_pull_request merged_pull_requests changed_files]
73
72
  expect(parsed_output["release_pull_request"]).to eq({ "data" => JSON.parse(@release_pr.to_hash.to_json) })
74
- expect(parsed_output["merged_pull_requests"]).to eq @merged_prs.map {|e| JSON.parse(PullRequest.new(e).to_hash.to_json) }
73
+ expect(parsed_output["merged_pull_requests"]).to eq @merged_prs.map {|e| JSON.parse(Git::Pr::Release::PullRequest.new(e).to_hash.to_json) }
75
74
  expect(parsed_output["changed_files"]).to eq @changed_files.map {|e| JSON.parse(e.to_hash.to_json) }
76
75
  }
77
76
  end
@@ -1,5 +1,6 @@
1
1
  require "timecop"
2
2
  require "yaml"
3
+ require "git/pr/release"
3
4
 
4
5
  RSpec.configure do |config|
5
6
  config.expect_with :rspec do |expectations|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-pr-release
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - motemen
8
8
  autorequire:
9
- bindir: bin
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2019-04-08 00:00:00.000000000 Z
11
+ date: 2019-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: octokit
@@ -109,9 +109,13 @@ files:
109
109
  - LICENSE
110
110
  - README.md
111
111
  - Rakefile
112
- - bin/git-pr-release
112
+ - exe/git-pr-release
113
113
  - git-pr-release.gemspec
114
- - spec/bin_spec.rb
114
+ - lib/git/pr/release.rb
115
+ - lib/git/pr/release/cli.rb
116
+ - lib/git/pr/release/dummy_pull_request.rb
117
+ - lib/git/pr/release/pull_request.rb
118
+ - lib/git/pr/release/util.rb
115
119
  - spec/fixtures/file/pr_1.yml
116
120
  - spec/fixtures/file/pr_1_files.yml
117
121
  - spec/fixtures/file/pr_3.yml
@@ -119,6 +123,7 @@ files:
119
123
  - spec/fixtures/file/pr_6.yml
120
124
  - spec/fixtures/file/template_1.erb
121
125
  - spec/fixtures/file/template_2.erb
126
+ - spec/git/pr/release_spec.rb
122
127
  - spec/spec_helper.rb
123
128
  - spec/support/capture_support.rb
124
129
  - spec/support/file_fixture_support.rb
@@ -141,13 +146,11 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
146
  - !ruby/object:Gem::Version
142
147
  version: '0'
143
148
  requirements: []
144
- rubyforge_project:
145
- rubygems_version: 2.7.6
149
+ rubygems_version: 3.0.3
146
150
  signing_key:
147
151
  specification_version: 4
148
152
  summary: Creates a release pull request
149
153
  test_files:
150
- - spec/bin_spec.rb
151
154
  - spec/fixtures/file/pr_1.yml
152
155
  - spec/fixtures/file/pr_1_files.yml
153
156
  - spec/fixtures/file/pr_3.yml
@@ -155,6 +158,7 @@ test_files:
155
158
  - spec/fixtures/file/pr_6.yml
156
159
  - spec/fixtures/file/template_1.erb
157
160
  - spec/fixtures/file/template_2.erb
161
+ - spec/git/pr/release_spec.rb
158
162
  - spec/spec_helper.rb
159
163
  - spec/support/capture_support.rb
160
164
  - spec/support/file_fixture_support.rb
@@ -1,412 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # -*- coding: utf-8 -*-
3
-
4
- require 'uri'
5
- require 'erb'
6
- require 'open3'
7
- require 'tmpdir'
8
- require 'json'
9
- require 'optparse'
10
-
11
- require 'octokit'
12
- require 'colorize'
13
- require 'diff/lcs'
14
-
15
- class PullRequest
16
- attr_reader :pr
17
-
18
- def initialize(pr)
19
- @pr = pr
20
- end
21
-
22
- def to_checklist_item
23
- "- [ ] ##{pr.number} #{pr.title}" + mention
24
- end
25
-
26
- def html_link
27
- pr.rels[:html].href
28
- end
29
-
30
- def to_hash
31
- { :data => @pr.to_hash }
32
- end
33
-
34
- def mention
35
- mention = case PullRequest.mention_type
36
- when 'author'
37
- pr.user ? "@#{pr.user.login}" : nil
38
- else
39
- pr.assignee ? "@#{pr.assignee.login}" : pr.user ? "@#{pr.user.login}" : nil
40
- end
41
-
42
- mention ? " #{mention}" : ""
43
- end
44
-
45
- def self.mention_type
46
- @mention_type ||= (git_config('mention') || 'default')
47
- end
48
- end
49
-
50
- class DummyPullRequest
51
- def initialize
52
- # nop
53
- end
54
-
55
- def to_checklist_item
56
- "- [ ] #??? THIS IS DUMMY PULL REQUEST"
57
- end
58
-
59
- def html_link
60
- 'http://github.com/DUMMY/DUMMY/issues/?'
61
- end
62
-
63
- def to_hash
64
- { :data => {} }
65
- end
66
- end
67
-
68
- def say(message, level)
69
- color = case level
70
- when :trace
71
- return unless ENV['DEBUG']
72
- nil
73
- when :debug
74
- return unless ENV['DEBUG']
75
- :blue
76
- when :info
77
- :green
78
- when :notice
79
- :yellow
80
- when :warn
81
- :magenta
82
- when :error
83
- :red
84
- end
85
-
86
- STDERR.puts message.colorize(color)
87
- end
88
-
89
- def git(*command)
90
- command = [ 'git', *command.map(&:to_s) ]
91
- say "Executing `#{command.join(' ')}`", :trace
92
- out, status = Open3.capture2(*command)
93
- unless status.success?
94
- raise "Executing `#{command.join(' ')}` failed: #{status}"
95
- end
96
- out.each_line
97
- end
98
-
99
-
100
- def git_config(key)
101
- host, _ = host_and_repository_and_scheme()
102
-
103
- plain_key = [ 'pr-release', key ].join('.')
104
- host_aware_key = [ 'pr-release', host, key ].compact.join('.')
105
-
106
- begin
107
- git(:config, '-f', '.git-pr-release', plain_key).first.chomp
108
- rescue
109
- git(:config, host_aware_key).first.chomp rescue nil
110
- end
111
- end
112
-
113
- def git_config_set(key, value)
114
- host, _ = host_and_repository_and_scheme()
115
- host_aware_key = [ 'pr-release', host, key ].compact.join('.')
116
-
117
- git :config, '--global', host_aware_key, value
118
- end
119
-
120
- # First line will be the title of the PR
121
- DEFAULT_PR_TEMPLATE = <<ERB
122
- Release <%= Time.now %>
123
- <% pull_requests.each do |pr| -%>
124
- <%= pr.to_checklist_item %>
125
- <% end -%>
126
- ERB
127
-
128
- def build_pr_title_and_body(release_pr, merged_prs, changed_files)
129
- release_pull_request = target_pull_request = release_pr ? PullRequest.new(release_pr) : DummyPullRequest.new
130
- merged_pull_requests = pull_requests = merged_prs.map { |pr| PullRequest.new(pr) }
131
-
132
- template = DEFAULT_PR_TEMPLATE
133
-
134
- if path = ENV.fetch('GIT_PR_RELEASE_TEMPLATE') { git_config('template') }
135
- template_path = File.join(git('rev-parse', '--show-toplevel').first.chomp, path)
136
- template = File.read(template_path)
137
- end
138
-
139
- erb = ERB.new template, nil, '-'
140
- content = erb.result binding
141
- content.split(/\n/, 2)
142
- end
143
-
144
- def dump_result_as_json(release_pr, merged_prs, changed_files)
145
- puts( {
146
- :release_pull_request => (release_pr ? PullRequest.new(release_pr) : DummyPullRequest.new).to_hash,
147
- :merged_pull_requests => merged_prs.map { |pr| PullRequest.new(pr).to_hash },
148
- :changed_files => changed_files.map { |file| file.to_hash }
149
- }.to_json )
150
- end
151
-
152
- def merge_pr_body(old_body, new_body)
153
- # Try to take over checklist statuses
154
- pr_body_lines = []
155
-
156
- check_status = {}
157
- old_body.split(/\r?\n/).each { |line|
158
- line.match(/^- \[(?<check_value>[ x])\] #(?<issue_number>\d+)/) { |m|
159
- say "Found pull-request checkbox \##{m[:issue_number]} is #{m[:check_value]}.", :trace
160
- check_status[m[:issue_number]] = m[:check_value]
161
- }
162
- }
163
- old_body_unchecked = old_body.gsub /^- \[[ x]\] \#(\d+)/, '- [ ] #\1'
164
-
165
- Diff::LCS.traverse_balanced(old_body_unchecked.split(/\r?\n/), new_body.split(/\r?\n/)) do |event|
166
- say "diff: #{event.inspect}", :trace
167
- action, old, new = *event
168
- old_nr, old_line = *old
169
- new_nr, new_line = *new
170
-
171
- case action
172
- when '=', '+'
173
- say "Use line as is: #{new_line}", :trace
174
- pr_body_lines << new_line
175
- when '-'
176
- say "Use old line: #{old_line}", :trace
177
- pr_body_lines << old_line
178
- when '!'
179
- if [ old_line, new_line ].all? { |line| /^- \[ \]/ === line }
180
- say "Found checklist diff; use old one: #{old_line}", :trace
181
- pr_body_lines << old_line
182
- else
183
- # not a checklist diff, use both line
184
- say "Use line as is: #{old_line}", :trace
185
- pr_body_lines << old_line
186
-
187
- say "Use line as is: #{new_line}", :trace
188
- pr_body_lines << new_line
189
- end
190
- else
191
- say "Unknown diff event: #{event}", :warn
192
- end
193
- end
194
-
195
- merged_body = pr_body_lines.join("\n")
196
- check_status.each { |issue_number, check_value|
197
- say "Update pull-request checkbox \##{issue_number} to #{check_value}.", :trace
198
- merged_body.gsub! /^- \[ \] \##{issue_number}/, "- [#{check_value}] \##{issue_number}"
199
- }
200
-
201
- merged_body
202
- end
203
-
204
- def obtain_token!
205
- token = ENV.fetch('GIT_PR_RELEASE_TOKEN') { git_config('token') }
206
-
207
- unless token
208
- require 'highline/import'
209
- STDERR.puts 'Could not obtain GitHub API token.'
210
- STDERR.puts 'Trying to generate token...'
211
-
212
- username = ask('username? ') { |q| q.default = ENV['USER'] }
213
- password = ask('password? (not saved) ') { |q| q.echo = '*' }
214
-
215
- temporary_client = Octokit::Client.new :login => username, :password => password
216
-
217
- auth = request_authorization(temporary_client, nil)
218
-
219
- token = auth.token
220
- git_config_set 'token', token
221
- end
222
-
223
- token
224
- end
225
-
226
- def request_authorization(client, two_factor_code)
227
- params = { :scopes => [ 'public_repo', 'repo' ], :note => 'git-pr-release' }
228
- params[:headers] = { "X-GitHub-OTP" => two_factor_code} if two_factor_code
229
-
230
- auth = nil
231
- begin
232
- auth = client.create_authorization(params)
233
- rescue Octokit::OneTimePasswordRequired
234
- two_factor_code = ask('two-factor authentication code? ')
235
- auth = request_authorization(client, two_factor_code)
236
- end
237
-
238
- auth
239
- end
240
-
241
- def host_and_repository_and_scheme
242
- @host_and_repository_and_scheme ||= begin
243
- remote = git(:config, 'remote.origin.url').first.chomp
244
- unless %r(^\w+://) === remote
245
- remote = "ssh://#{remote.sub(':', '/')}"
246
- end
247
-
248
- remote_url = URI.parse(remote)
249
- repository = remote_url.path.sub(%r(^/), '').sub(/\.git$/, '')
250
-
251
- host = remote_url.host == 'github.com' ? nil : remote_url.host
252
- [ host, repository, remote_url.scheme === 'http' ? 'http' : 'https' ]
253
- end
254
- end
255
-
256
- # Fetch PR files of specified pull_request
257
- def pull_request_files(client, pull_request)
258
- return [] if pull_request.nil?
259
-
260
- host, repository, scheme = host_and_repository_and_scheme
261
- return client.pull_request_files repository, pull_request.number
262
- end
263
-
264
- def main
265
- host, repository, scheme = host_and_repository_and_scheme
266
-
267
- if host
268
- # GitHub:Enterprise
269
- OpenSSL::SSL.const_set :VERIFY_PEER, OpenSSL::SSL::VERIFY_NONE # XXX
270
-
271
- Octokit.configure do |c|
272
- c.api_endpoint = "#{scheme}://#{host}/api/v3"
273
- c.web_endpoint = "#{scheme}://#{host}/"
274
- end
275
- end
276
-
277
- OptionParser.new do |opts|
278
- opts.on('-n', '--dry-run', 'Do not create/update a PR. Just prints out') do |v|
279
- @dry_run = v
280
- end
281
- opts.on('--json', 'Show data of target PRs in JSON format') do |v|
282
- @json = v
283
- end
284
- opts.on('--no-fetch', 'Do not fetch from remote repo before determining target PRs (CI friendly)') do |v|
285
- @no_fetch = v
286
- end
287
- end.parse!
288
-
289
- ### Set up configuration
290
-
291
- production_branch = ENV.fetch('GIT_PR_RELEASE_BRANCH_PRODUCTION') { git_config('branch.production') } || 'master'
292
- staging_branch = ENV.fetch('GIT_PR_RELEASE_BRANCH_STAGING') { git_config('branch.staging') } || 'staging'
293
-
294
- say "Repository: #{repository}", :debug
295
- say "Production branch: #{production_branch}", :debug
296
- say "Staging branch: #{staging_branch}", :debug
297
-
298
- client = Octokit::Client.new :access_token => obtain_token!
299
-
300
- git :remote, 'update', 'origin' unless @no_fetch
301
-
302
- ### Fetch merged PRs
303
-
304
- merged_feature_head_sha1s = git(
305
- :log, '--merges', '--pretty=format:%P', "origin/#{production_branch}..origin/#{staging_branch}"
306
- ).map do |line|
307
- main_sha1, feature_sha1 = line.chomp.split /\s+/
308
- feature_sha1
309
- end
310
-
311
- merged_pull_request_numbers = git('ls-remote', 'origin', 'refs/pull/*/head').map do |line|
312
- sha1, ref = line.chomp.split /\s+/
313
-
314
- if merged_feature_head_sha1s.include? sha1
315
- if %r<^refs/pull/(\d+)/head$>.match ref
316
- pr_number = $1.to_i
317
-
318
- if git('merge-base', sha1, "origin/#{production_branch}").first.chomp == sha1
319
- say "##{pr_number} (#{sha1}) is already merged into #{production_branch}", :debug
320
- else
321
- pr_number
322
- end
323
- else
324
- say "Bad pull request head ref format: #{ref}", :warn
325
- nil
326
- end
327
- end
328
- end.compact
329
-
330
- if merged_pull_request_numbers.empty?
331
- say 'No pull requests to be released', :error
332
- exit 1
333
- end
334
-
335
- merged_prs = merged_pull_request_numbers.map do |nr|
336
- pr = client.pull_request repository, nr
337
- say "To be released: ##{pr.number} #{pr.title}", :notice
338
- pr
339
- end
340
-
341
- ### Create a release PR
342
-
343
- say 'Searching for existing release pull requests...', :info
344
- found_release_pr = client.pull_requests(repository).find do |pr|
345
- pr.head.ref == staging_branch && pr.base.ref == production_branch
346
- end
347
- create_mode = found_release_pr.nil?
348
-
349
- # Fetch changed files of a release PR
350
- changed_files = pull_request_files(client, found_release_pr)
351
-
352
- if @dry_run
353
- pr_title, new_body = build_pr_title_and_body found_release_pr, merged_prs, changed_files
354
- pr_body = create_mode ? new_body : merge_pr_body(found_release_pr.body, new_body)
355
-
356
- say 'Dry-run. Not updating PR', :info
357
- say pr_title, :notice
358
- say pr_body, :notice
359
- dump_result_as_json( found_release_pr, merged_prs, changed_files ) if @json
360
- exit 0
361
- end
362
-
363
- pr_title, pr_body = nil, nil
364
- release_pr = nil
365
-
366
- if create_mode
367
- created_pr = client.create_pull_request(
368
- repository, production_branch, staging_branch, 'Preparing release pull request...', ''
369
- )
370
- unless created_pr
371
- say 'Failed to create a new pull request', :error
372
- exit 2
373
- end
374
- changed_files = pull_request_files(client, created_pr) # Refetch changed files from created_pr
375
- pr_title, pr_body = build_pr_title_and_body created_pr, merged_prs, changed_files
376
- release_pr = created_pr
377
- else
378
- pr_title, new_body = build_pr_title_and_body found_release_pr, merged_prs, changed_files
379
- pr_body = merge_pr_body(found_release_pr.body, new_body)
380
- release_pr = found_release_pr
381
- end
382
-
383
- say 'Pull request body:', :debug
384
- say pr_body, :debug
385
-
386
- updated_pull_request = client.update_pull_request(
387
- repository, release_pr.number, :title => pr_title, :body => pr_body
388
- )
389
-
390
- unless updated_pull_request
391
- say 'Failed to update a pull request', :error
392
- exit 3
393
- end
394
-
395
- labels = ENV.fetch('GIT_PR_RELEASE_LABELS') { git_config('labels') }
396
- if not labels.nil? and not labels.empty?
397
- labels = labels.split(/\s*,\s*/)
398
- labeled_pull_request = client.add_labels_to_an_issue(
399
- repository, release_pr.number, labels
400
- )
401
-
402
- unless labeled_pull_request
403
- say 'Failed to add labels to a pull request', :error
404
- exit 4
405
- end
406
- end
407
-
408
- say "#{create_mode ? 'Created' : 'Updated'} pull request: #{updated_pull_request.rels[:html].href}", :notice
409
- dump_result_as_json( release_pr, merged_prs, changed_files ) if @json
410
- end
411
-
412
- main if $0 == __FILE__ || $0 == File.join(Gem.dir, "bin", "git-pr-release")