git-review 0.5.1 → 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/git-review.rb +143 -182
- metadata +7 -21
data/lib/git-review.rb
CHANGED
@@ -1,11 +1,10 @@
|
|
1
|
-
|
2
|
-
require 'launchy'
|
1
|
+
# Octokit is used to access GitHub's API.
|
3
2
|
require 'octokit'
|
3
|
+
# Launchy is used in 'browse' to open a browser.
|
4
|
+
require 'launchy'
|
4
5
|
|
5
6
|
class GitReview
|
6
7
|
|
7
|
-
REVIEW_CACHE_FILE = '.git/review_cache.json'
|
8
|
-
|
9
8
|
## COMMANDS ##
|
10
9
|
|
11
10
|
# Default command to show a quick reference of available commands.
|
@@ -14,7 +13,7 @@ class GitReview
|
|
14
13
|
puts 'Manage review workflow for projects hosted on GitHub (using pull requests).'
|
15
14
|
puts ''
|
16
15
|
puts 'Available commands:'
|
17
|
-
puts ' list [--reverse] List all
|
16
|
+
puts ' list [--reverse] List all pending requests.'
|
18
17
|
puts ' show <number> [--full] Show details of a single request.'
|
19
18
|
puts ' browse <number> Open a browser window and review a specified request.'
|
20
19
|
puts ' checkout <number> Checkout a specified request\'s changes to your local repository.'
|
@@ -23,98 +22,89 @@ class GitReview
|
|
23
22
|
puts ' create Create a new request.'
|
24
23
|
end
|
25
24
|
|
26
|
-
# List all
|
25
|
+
# List all pending requests.
|
27
26
|
def list
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
27
|
+
@pending_requests.reverse! if @args.shift == '--reverse'
|
28
|
+
output = @pending_requests.collect do |pending_request|
|
29
|
+
# Find only pending (= unmerged) requests and output summary. GitHub might
|
30
|
+
# still think of them as pending, as it doesn't know about local changes.
|
31
|
+
next if merged?(pending_request['head']['sha'])
|
32
|
+
line = format_text(pending_request['number'], 8)
|
33
|
+
date_string = Date.parse(pending_request['updated_at']).strftime('%d-%b-%y')
|
34
|
+
line += format_text(date_string, 11)
|
35
|
+
line += format_text(pending_request['comments'], 10)
|
36
|
+
line += format_text(pending_request['title'], 91)
|
37
|
+
line
|
32
38
|
end
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
open_requests.each do |pull|
|
37
|
-
next unless not_merged?(pull['head']['sha'])
|
38
|
-
line = []
|
39
|
-
line << format_text(pull['number'], 6)
|
40
|
-
line << format_text(Date.parse(pull['created_at']).strftime('%d-%b-%y'), 10)
|
41
|
-
line << format_text(pull['comments'], 10)
|
42
|
-
line << format_text(pull['title'], 94)
|
43
|
-
puts line.join ' '
|
39
|
+
if output.compact.empty?
|
40
|
+
puts "No pending requests for '#{source}'"
|
41
|
+
return
|
44
42
|
end
|
43
|
+
puts "Pending requests for '#{source}'"
|
44
|
+
puts 'ID Date Comments Title'
|
45
|
+
puts output.compact
|
45
46
|
end
|
46
47
|
|
47
48
|
# Show details of a single request.
|
48
49
|
def show
|
49
|
-
return unless
|
50
|
-
option = @args.shift
|
51
|
-
|
52
|
-
puts "
|
53
|
-
puts "
|
54
|
-
puts "
|
55
|
-
puts "
|
56
|
-
puts
|
57
|
-
puts
|
50
|
+
return unless request_exists?
|
51
|
+
option = @args.shift == '--full' ? '' : '--stat '
|
52
|
+
sha = @pending_request['head']['sha']
|
53
|
+
puts "Number : #{@pending_request['number']}"
|
54
|
+
puts "Label : #{@pending_request['head']['label']}"
|
55
|
+
puts "Created : #{@pending_request['created_at']}"
|
56
|
+
puts "Votes : #{@pending_request['votes']}"
|
57
|
+
puts "Comments : #{@pending_request['comments']}"
|
58
|
+
puts ''
|
59
|
+
puts "Title : #{@pending_request['title']}"
|
58
60
|
puts "Body :"
|
59
|
-
puts
|
60
|
-
puts @
|
61
|
-
puts
|
61
|
+
puts ''
|
62
|
+
puts @pending_request['body']
|
63
|
+
puts ''
|
62
64
|
puts '------------'
|
63
|
-
puts
|
64
|
-
|
65
|
-
|
66
|
-
else
|
67
|
-
puts "cmd: git diff HEAD...#{@review['head']['sha']}"
|
68
|
-
puts git("diff --stat --color=always HEAD...#{@review['head']['sha']}")
|
69
|
-
end
|
65
|
+
puts ''
|
66
|
+
puts "cmd: git diff #{option}HEAD...#{sha}"
|
67
|
+
puts git("diff --color=always #{option}HEAD...#{sha}")
|
70
68
|
end
|
71
69
|
|
72
70
|
# Open a browser window and review a specified request.
|
73
71
|
def browse
|
74
|
-
Launchy.open(@
|
72
|
+
Launchy.open(@pending_request['html_url']) if request_exists?
|
75
73
|
end
|
76
74
|
|
77
75
|
# Checkout a specified request's changes to your local repository.
|
78
76
|
def checkout
|
79
|
-
|
80
|
-
git "co origin/#{@review['head']['ref']}"
|
77
|
+
git "co origin/#{@pending_request['head']['ref']}" if request_exists?
|
81
78
|
end
|
82
79
|
|
83
80
|
# Accept a specified request by merging it into master.
|
84
81
|
def accept
|
85
|
-
return unless
|
82
|
+
return unless request_exists?
|
86
83
|
option = @args.shift
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
purl = @review['patch_url']
|
93
|
-
puts "Sorry, #{o} deleted the source repository, git-review doesn't support this."
|
84
|
+
unless @pending_request['head']['repository']
|
85
|
+
# Someone deleted the source repo.
|
86
|
+
user = @pending_request['head']['user']['login']
|
87
|
+
url = @pending_request['patch_url']
|
88
|
+
puts "Sorry, #{user} deleted the source repository, git-review doesn't support this."
|
94
89
|
puts "You can manually patch your repo by running:"
|
95
90
|
puts
|
96
|
-
puts " curl #{
|
91
|
+
puts " curl #{url} | git am"
|
97
92
|
puts
|
98
93
|
puts "Tell the contributor not to do this."
|
99
94
|
return false
|
100
95
|
end
|
101
|
-
|
102
|
-
message
|
103
|
-
message += @
|
104
|
-
|
105
|
-
message += "\n\n---\n\nMerge Log:\n"
|
106
|
-
puts cmd = "git merge --no-ff --log -m '#{message}' #{s}"
|
107
|
-
else
|
108
|
-
puts cmd = "git merge --no-ff -m '#{message}' #{s}"
|
109
|
-
end
|
96
|
+
message = "Accepting request and merging into '#{target}':\n\n"
|
97
|
+
message += "#{@pending_request['title']}\n\n"
|
98
|
+
message += "#{@pending_request['body']}\n\n"
|
99
|
+
puts cmd = "git merge --no-ff #{option} -m '#{message}' #{@pending_request['head']['sha']}"
|
110
100
|
exec(cmd)
|
111
101
|
end
|
112
102
|
|
113
103
|
# Decline and close a specified request.
|
114
104
|
def decline
|
115
|
-
return unless
|
116
|
-
Octokit.post("issues/close/#{source_repo}/#{@
|
117
|
-
puts "Successfully declined request." unless
|
105
|
+
return unless request_exists?
|
106
|
+
Octokit.post("issues/close/#{source_repo}/#{@pending_request['number']}")
|
107
|
+
puts "Successfully declined request." unless request_exists?(@pending_request['number'])
|
118
108
|
end
|
119
109
|
|
120
110
|
# Create a new request.
|
@@ -122,8 +112,8 @@ class GitReview
|
|
122
112
|
def create
|
123
113
|
# TODO: Create and push to a remote branch if necessary.
|
124
114
|
# Gather information.
|
125
|
-
|
126
|
-
title = "[Review] Request from '#{github_login}' @ '#{
|
115
|
+
last_request_id = @pending_requests.collect{|req| req['number'] }.sort.last.to_i
|
116
|
+
title = "[Review] Request from '#{github_login}' @ '#{source}'"
|
127
117
|
# TODO: Insert commit messages (that are not yet in master) into body (since this will be displayed inside the mail that is sent out).
|
128
118
|
body = "You are requested to review the following changes:"
|
129
119
|
# Create the actual pull request.
|
@@ -131,8 +121,8 @@ class GitReview
|
|
131
121
|
# Switch back to target_branch and check for success.
|
132
122
|
git "co #{target_branch}"
|
133
123
|
update
|
134
|
-
|
135
|
-
puts '
|
124
|
+
potential_new_request = @pending_requests.find{ |req| req['title'] == title }
|
125
|
+
puts 'Successfully created new request.' if potential_new_request['number'] > last_request_id
|
136
126
|
end
|
137
127
|
|
138
128
|
# Start a console session (used for debugging).
|
@@ -152,37 +142,46 @@ class GitReview
|
|
152
142
|
command = args.shift
|
153
143
|
@user, @repo = repo_info
|
154
144
|
@args = args
|
155
|
-
|
145
|
+
configure_github_access
|
156
146
|
if command && self.respond_to?(command)
|
157
147
|
update
|
158
148
|
self.send command
|
159
149
|
else
|
160
|
-
unless command.
|
150
|
+
unless command.nil? or command.empty? or %w(-h --help).include?(command)
|
161
151
|
puts "git-review: '#{command}' is not a valid command.\n\n"
|
162
152
|
end
|
163
153
|
help
|
164
154
|
end
|
165
155
|
end
|
166
156
|
|
167
|
-
#
|
168
|
-
def
|
169
|
-
|
170
|
-
fetch_stale_forks
|
171
|
-
end
|
172
|
-
|
173
|
-
# Check existence of specified review and assign @review.
|
174
|
-
def review_exists?(review_id = nil)
|
175
|
-
# NOTE: If review_id is not set explicitly we might need to update to get the
|
157
|
+
# Check existence of specified request and assign @pending_request.
|
158
|
+
def request_exists?(request_id = nil)
|
159
|
+
# NOTE: If request_id is set explicitly we might need to update to get the
|
176
160
|
# latest changes from GitHub, as this is called from within another method.
|
177
|
-
update
|
178
|
-
|
179
|
-
if
|
161
|
+
update unless request_id.nil?
|
162
|
+
request_id ||= @args.shift.to_i
|
163
|
+
if request_id == 0
|
180
164
|
puts "Please specify a valid ID."
|
181
165
|
return false
|
182
166
|
end
|
183
|
-
@
|
184
|
-
puts "
|
185
|
-
not @
|
167
|
+
@pending_request = @pending_requests.find{ |req| req['number'] == request_id }
|
168
|
+
puts "Request '#{request_id}' does not exist." unless @pending_request
|
169
|
+
not @pending_request.nil?
|
170
|
+
end
|
171
|
+
|
172
|
+
# Get latest changes from GitHub.
|
173
|
+
def update
|
174
|
+
@pending_requests = Octokit.pull_requests(source_repo)
|
175
|
+
repos = @pending_requests.collect do |req|
|
176
|
+
repo = req['head']['repository']
|
177
|
+
# Check if fork and commits still exist.
|
178
|
+
next if repo.nil? or not has_sha?(req['head']['sha'])
|
179
|
+
"#{repo['owner']}/#{repo['name']}"
|
180
|
+
end
|
181
|
+
host = URI.parse(github_endpoint).host
|
182
|
+
repos.uniq.compact.each do |repo|
|
183
|
+
git("fetch git@#{host}:#{repo}.git +refs/heads/*:refs/pr/#{repo}/*")
|
184
|
+
end
|
186
185
|
end
|
187
186
|
|
188
187
|
# System call to 'git'.
|
@@ -192,9 +191,9 @@ class GitReview
|
|
192
191
|
s
|
193
192
|
end
|
194
193
|
|
195
|
-
# Display helper to make output more
|
194
|
+
# Display helper to make output more configurable.
|
196
195
|
def format_text(info, size)
|
197
|
-
info.to_s.gsub("\n", ' ')[0, size].ljust(size)
|
196
|
+
info.to_s.gsub("\n", ' ')[0, size-1].ljust(size)
|
198
197
|
end
|
199
198
|
|
200
199
|
# Returns a string that specifies the source repo.
|
@@ -207,6 +206,11 @@ class GitReview
|
|
207
206
|
git('branch', false).match(/\*(.*)/)[0][2..-1]
|
208
207
|
end
|
209
208
|
|
209
|
+
# Returns a string consisting of source repo and branch.
|
210
|
+
def source
|
211
|
+
"#{source_repo}/#{source_branch}"
|
212
|
+
end
|
213
|
+
|
210
214
|
# Returns a string that specifies the target repo.
|
211
215
|
def target_repo
|
212
216
|
# TODO: Enable possibility to manually override this and set arbitrary repositories.
|
@@ -219,131 +223,88 @@ class GitReview
|
|
219
223
|
'master'
|
220
224
|
end
|
221
225
|
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
pulls.each do |pull|
|
226
|
-
next if pull['head']['repository'].nil? # Fork has been deleted
|
227
|
-
o = pull['head']['repository']['owner']
|
228
|
-
r = pull['head']['repository']['name']
|
229
|
-
s = pull['head']['sha']
|
230
|
-
if !has_sha(s)
|
231
|
-
repo = "#{o}/#{r}"
|
232
|
-
repos[repo] = true
|
233
|
-
end
|
234
|
-
end
|
235
|
-
if github_credentials_provided?
|
236
|
-
endpoint = "git@github.com:"
|
237
|
-
else
|
238
|
-
endpoint = github_endpoint + "/"
|
239
|
-
end
|
240
|
-
repos.each do |repo, bool|
|
241
|
-
puts "fetching #{repo}"
|
242
|
-
git("fetch #{endpoint}#{repo}.git +refs/heads/*:refs/pr/#{repo}/*")
|
243
|
-
end
|
226
|
+
# Returns a string consisting of target repo and branch.
|
227
|
+
def target
|
228
|
+
"#{target_repo}/#{target_branch}"
|
244
229
|
end
|
245
230
|
|
246
|
-
|
231
|
+
# Returns a boolean stating whether a specified commit exists.
|
232
|
+
# TODO: Check if this is still necessary, since we don't cache anymore.
|
233
|
+
def has_sha?(sha)
|
247
234
|
git("show #{sha} 2>&1")
|
248
235
|
$?.exitstatus == 0
|
249
236
|
end
|
250
237
|
|
251
|
-
|
252
|
-
|
253
|
-
|
238
|
+
# Returns a boolean stating whether a specified commit has already been merged.
|
239
|
+
def merged?(sha)
|
240
|
+
not git("rev-list #{sha} ^HEAD 2>&1").split("\n").size > 0
|
254
241
|
end
|
255
242
|
|
256
|
-
#
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
243
|
+
# Checks '~/.gitconfig' for credentials and
|
244
|
+
def configure_github_access
|
245
|
+
if (github_token.empty? or github_login.empty?)
|
246
|
+
puts 'Please update your git config and provide your GitHub user name and token.'
|
247
|
+
puts 'Some commands won\'t work properly without these credentials.'
|
248
|
+
else
|
249
|
+
Octokit.configure do |config|
|
250
|
+
config.login = github_login
|
251
|
+
config.token = github_token
|
252
|
+
config.endpoint = github_endpoint
|
253
|
+
end
|
263
254
|
end
|
264
255
|
end
|
265
256
|
|
257
|
+
# Get GitHub user name.
|
266
258
|
def github_login
|
267
259
|
git("config --get-all github.user")
|
268
260
|
end
|
269
261
|
|
262
|
+
# Get GitHub token.
|
270
263
|
def github_token
|
271
264
|
git("config --get-all github.token")
|
272
265
|
end
|
273
266
|
|
267
|
+
# Determine GitHub endpoint (defaults to 'https://github.com/').
|
274
268
|
def github_endpoint
|
275
269
|
host = git("config --get-all github.host")
|
276
|
-
|
277
|
-
host
|
278
|
-
else
|
279
|
-
'https://github.com'
|
280
|
-
end
|
281
|
-
end
|
282
|
-
|
283
|
-
# API/DATA HELPER FUNCTIONS #
|
284
|
-
|
285
|
-
def github_credentials_provided?
|
286
|
-
if github_token.empty? && github_login.empty?
|
287
|
-
return false
|
288
|
-
end
|
289
|
-
true
|
290
|
-
end
|
291
|
-
|
292
|
-
def get_pull_info
|
293
|
-
get_data(REVIEW_CACHE_FILE)['review']
|
294
|
-
end
|
295
|
-
|
296
|
-
def get_data(file)
|
297
|
-
JSON.parse(File.read(file))
|
298
|
-
end
|
299
|
-
|
300
|
-
def cache_pull_info
|
301
|
-
response = Octokit.pull_requests(source_repo)
|
302
|
-
save_data({'review' => response}, REVIEW_CACHE_FILE)
|
303
|
-
end
|
304
|
-
|
305
|
-
def save_data(data, file)
|
306
|
-
File.open(file, "w+") do |f|
|
307
|
-
f.puts data.to_json
|
308
|
-
end
|
309
|
-
end
|
310
|
-
|
311
|
-
def github_insteadof_matching(c, u)
|
312
|
-
first = c.collect {|k,v| [v, /url\.(.*github\.com.*)\.insteadof/.match(k)]}.
|
313
|
-
find {|v,m| u.index(v) and m != nil}
|
314
|
-
if first
|
315
|
-
return first[0], first[1][1]
|
316
|
-
end
|
317
|
-
return nil, nil
|
318
|
-
end
|
319
|
-
|
320
|
-
def github_user_and_proj(u)
|
321
|
-
# Trouble getting optional ".git" at end to work, so put that logic below
|
322
|
-
m = /github\.com.(.*?)\/(.*)/.match(u)
|
323
|
-
if m
|
324
|
-
return m[1], m[2].sub(/\.git\Z/, "")
|
325
|
-
end
|
326
|
-
return nil, nil
|
270
|
+
host.empty? ? 'https://github.com/' : host
|
327
271
|
end
|
328
272
|
|
329
273
|
def repo_info
|
330
|
-
|
274
|
+
# Read config_hash from local git config.
|
275
|
+
config_hash = {}
|
331
276
|
config = git('config --list')
|
332
277
|
config.split("\n").each do |line|
|
333
|
-
|
334
|
-
|
278
|
+
key, value = line.split('=')
|
279
|
+
config_hash[key] = value
|
335
280
|
end
|
336
|
-
|
337
|
-
|
338
|
-
user,
|
339
|
-
|
340
|
-
|
281
|
+
# Extract user and project name from GitHub URL.
|
282
|
+
url = config_hash['remote.origin.url']
|
283
|
+
user, project = github_user_and_project(url)
|
284
|
+
# If there are no results yet, look for 'insteadof' substitutions in URL and try again.
|
285
|
+
unless (user and project)
|
286
|
+
short, base = github_insteadof_matching(config_hash, url)
|
341
287
|
if short and base
|
342
|
-
|
343
|
-
user,
|
288
|
+
url = url.sub(short, base)
|
289
|
+
user, project = github_user_and_project(url)
|
344
290
|
end
|
345
291
|
end
|
346
|
-
[user,
|
292
|
+
[user, project]
|
293
|
+
end
|
294
|
+
|
295
|
+
def github_insteadof_matching(config_hash, url)
|
296
|
+
first = config_hash.collect { |key,value|
|
297
|
+
[value, /url\.(.*github\.com.*)\.insteadof/.match(key)]
|
298
|
+
}.find { |value, match|
|
299
|
+
url.index(value) and match != nil
|
300
|
+
}
|
301
|
+
first ? [first[0], first[1][1]] : [nil, nil]
|
302
|
+
end
|
303
|
+
|
304
|
+
# Extract user and project name from GitHub URL.
|
305
|
+
def github_user_and_project(github_url)
|
306
|
+
matches = /github\.com.(.*?)\/(.*)/.match(github_url)
|
307
|
+
matches ? [matches[1], matches[2].sub(/\.git\z/, '')] : [nil, nil]
|
347
308
|
end
|
348
309
|
|
349
310
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: git-review
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 5
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
|
-
-
|
8
|
+
- 6
|
9
9
|
- 1
|
10
|
-
version: 0.
|
10
|
+
version: 0.6.1
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Dominik Bamberger, Cristian Messel
|
@@ -15,11 +15,11 @@ autorequire:
|
|
15
15
|
bindir: bin
|
16
16
|
cert_chain: []
|
17
17
|
|
18
|
-
date: 2011-07-
|
18
|
+
date: 2011-07-18 00:00:00 +02:00
|
19
19
|
default_executable:
|
20
20
|
dependencies:
|
21
21
|
- !ruby/object:Gem::Dependency
|
22
|
-
name:
|
22
|
+
name: launchy
|
23
23
|
prerelease: false
|
24
24
|
requirement: &id001 !ruby/object:Gem::Requirement
|
25
25
|
none: false
|
@@ -32,24 +32,10 @@ dependencies:
|
|
32
32
|
version: "0"
|
33
33
|
type: :runtime
|
34
34
|
version_requirements: *id001
|
35
|
-
- !ruby/object:Gem::Dependency
|
36
|
-
name: launchy
|
37
|
-
prerelease: false
|
38
|
-
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
-
none: false
|
40
|
-
requirements:
|
41
|
-
- - ">="
|
42
|
-
- !ruby/object:Gem::Version
|
43
|
-
hash: 3
|
44
|
-
segments:
|
45
|
-
- 0
|
46
|
-
version: "0"
|
47
|
-
type: :runtime
|
48
|
-
version_requirements: *id002
|
49
35
|
- !ruby/object:Gem::Dependency
|
50
36
|
name: octokit
|
51
37
|
prerelease: false
|
52
|
-
requirement: &
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
53
39
|
none: false
|
54
40
|
requirements:
|
55
41
|
- - "="
|
@@ -61,7 +47,7 @@ dependencies:
|
|
61
47
|
- 1
|
62
48
|
version: 0.5.1
|
63
49
|
type: :runtime
|
64
|
-
version_requirements: *
|
50
|
+
version_requirements: *id002
|
65
51
|
description: Manage review workflow for projects hosted on GitHub (using pull requests).
|
66
52
|
email: bamberger.dominik@gmail.com
|
67
53
|
executables:
|