git-pr-release 0.8.0 → 1.0.0

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.
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")