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 +4 -4
- data/exe/git-pr-release +5 -0
- data/git-pr-release.gemspec +3 -2
- data/lib/git/pr/release.rb +11 -0
- data/lib/git/pr/release/cli.rb +159 -0
- data/lib/git/pr/release/dummy_pull_request.rb +23 -0
- data/lib/git/pr/release/pull_request.rb +42 -0
- data/lib/git/pr/release/util.rb +209 -0
- data/spec/{bin_spec.rb → git/pr/release_spec.rb} +3 -4
- data/spec/spec_helper.rb +1 -0
- metadata +12 -8
- data/bin/git-pr-release +0 -412
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9184a8468614334e1e8b4d4a61e56405c033afae2199e7288cfee2154ec6c444
|
4
|
+
data.tar.gz: 76075368c8268080461c713b2fa1621a6cf714fd5ea95c39763278f90d2479d9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 84befebc97d17f0ad0a85347fa489ccfde2713ac77f3a0455f8f576c3fe191f7b566137a8efbeb369cf1e65b171d15afbdf93a89f8e1452c54556f042bd42e65
|
7
|
+
data.tar.gz: 0d1937de82dd289dad1c323f9e6f35c0503a638dba37b235691ae79a5a75b079719213964a74135a782a630dc70a40fdb5f73cfa8940dd764c72f555f97a7eb5
|
data/exe/git-pr-release
ADDED
data/git-pr-release.gemspec
CHANGED
@@ -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.
|
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.
|
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,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
|
-
|
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
|
data/spec/spec_helper.rb
CHANGED
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.
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- motemen
|
8
8
|
autorequire:
|
9
|
-
bindir:
|
9
|
+
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
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
|
-
-
|
112
|
+
- exe/git-pr-release
|
113
113
|
- git-pr-release.gemspec
|
114
|
-
-
|
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
|
-
|
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
|
data/bin/git-pr-release
DELETED
@@ -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")
|