git-review 1.1.6 → 1.1.7
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/git-review/commands.rb +331 -0
- data/lib/git-review/errors.rb +34 -0
- data/lib/git-review/github.rb +289 -0
- data/lib/git-review/internals.rb +47 -0
- data/lib/git-review/local.rb +227 -0
- data/lib/git-review/settings.rb +48 -0
- metadata +13 -6
@@ -0,0 +1,331 @@
|
|
1
|
+
module GitReview
|
2
|
+
|
3
|
+
module Commands
|
4
|
+
|
5
|
+
include ::GitReview::Internals
|
6
|
+
extend self
|
7
|
+
|
8
|
+
# List all pending requests.
|
9
|
+
def list(reverse=false)
|
10
|
+
requests = github.current_requests_full.reject { |request|
|
11
|
+
# find only pending (= unmerged) requests and output summary
|
12
|
+
# explicitly look for local changes Github does not yet know about
|
13
|
+
local.merged?(request.head.sha)
|
14
|
+
}
|
15
|
+
requests.reverse! if reverse
|
16
|
+
source = local.source
|
17
|
+
if requests.empty?
|
18
|
+
puts "No pending requests for '#{source}'."
|
19
|
+
else
|
20
|
+
puts "Pending requests for '#{source}':"
|
21
|
+
puts "ID Updated Comments Title"
|
22
|
+
requests.each { |request| print_request(request) }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Show details for a single request.
|
27
|
+
def show(number, full=false)
|
28
|
+
request = get_request_by_number(number)
|
29
|
+
# determine whether to show full diff or just stats
|
30
|
+
option = full ? '' : '--stat '
|
31
|
+
diff = "diff --color=always #{option}HEAD...#{request.head.sha}"
|
32
|
+
print_request_details(request)
|
33
|
+
puts git_call(diff)
|
34
|
+
print_request_discussions(request)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Open a browser window and review a specified request.
|
38
|
+
def browse(number)
|
39
|
+
request = get_request_by_number(number)
|
40
|
+
Launchy.open(request.html_url)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Checkout a specified request's changes to your local repository.
|
44
|
+
def checkout(number, branch=false)
|
45
|
+
request = get_request_by_number(number)
|
46
|
+
puts 'Checking out changes to your local repository.'
|
47
|
+
puts 'To get back to your original state, just run:'
|
48
|
+
puts
|
49
|
+
puts ' git checkout master'
|
50
|
+
puts
|
51
|
+
if branch
|
52
|
+
git_call("checkout #{request.head.ref}")
|
53
|
+
else
|
54
|
+
git_call("checkout pr/#{request.number}")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Add an approving comment to the request.
|
59
|
+
def approve(number)
|
60
|
+
request = get_request_by_number(number)
|
61
|
+
repo = github.source_repo
|
62
|
+
# TODO: Make this configurable.
|
63
|
+
comment = 'Reviewed and approved.'
|
64
|
+
response = github.add_comment(repo, request.number, comment)
|
65
|
+
if response[:body] == comment
|
66
|
+
puts 'Successfully approved request.'
|
67
|
+
else
|
68
|
+
puts response[:message]
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Accept a specified request by merging it into master.
|
73
|
+
def merge(number)
|
74
|
+
request = get_request_by_number(number)
|
75
|
+
if request.head.repo
|
76
|
+
message = "Accept request ##{request.number} " +
|
77
|
+
"and merge changes into \"#{local.target}\""
|
78
|
+
command = "merge -m '#{message}' #{request.head.sha}"
|
79
|
+
puts
|
80
|
+
puts "Request title:"
|
81
|
+
puts " #{request.title}"
|
82
|
+
puts
|
83
|
+
puts "Merge command:"
|
84
|
+
puts " git #{command}"
|
85
|
+
puts
|
86
|
+
puts git_call(command)
|
87
|
+
else
|
88
|
+
print_repo_deleted(request)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Close a specified request.
|
93
|
+
def close(number)
|
94
|
+
request = get_request_by_number(number)
|
95
|
+
repo = github.source_repo
|
96
|
+
github.close_issue(repo, request.number)
|
97
|
+
unless github.request_exists?('open', request.number)
|
98
|
+
puts 'Successfully closed request.'
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Prepare local repository to create a new request.
|
103
|
+
# People should work on local branches, but especially for single commit
|
104
|
+
# changes, more often than not, they don't. Therefore we create a branch
|
105
|
+
# for them, to be able to use code review the way it is intended.
|
106
|
+
# @return [Array(String, String)] the original branch and the local branch
|
107
|
+
def prepare(new=false, name=nil)
|
108
|
+
# remember original branch the user was currently working on
|
109
|
+
original_branch = local.source_branch
|
110
|
+
if new || !local.on_feature_branch?
|
111
|
+
local_branch = move_uncommitted_changes(local.target_branch, name)
|
112
|
+
else
|
113
|
+
local_branch = original_branch
|
114
|
+
end
|
115
|
+
[original_branch, local_branch]
|
116
|
+
end
|
117
|
+
|
118
|
+
# Create a new request.
|
119
|
+
# TODO: Support creating requests to other repositories and branches (like
|
120
|
+
# the original repo, this has been forked from).
|
121
|
+
def create(upstream=false)
|
122
|
+
# prepare original_branch and local_branch
|
123
|
+
original_branch, local_branch = prepare
|
124
|
+
# don't create request with uncommitted changes in current branch
|
125
|
+
unless git_call('diff HEAD').empty?
|
126
|
+
puts 'You have uncommitted changes.'
|
127
|
+
puts 'Please stash or commit before creating the request.'
|
128
|
+
return
|
129
|
+
end
|
130
|
+
if git_call("cherry #{local.target_branch}").empty?
|
131
|
+
puts 'Nothing to push to remote yet. Commit something first.'
|
132
|
+
else
|
133
|
+
if github.request_exists_for_branch?(upstream)
|
134
|
+
puts 'A pull request already exists for this branch.'
|
135
|
+
puts 'Please update the request directly using `git push`.'
|
136
|
+
return
|
137
|
+
end
|
138
|
+
# push latest commits to the remote branch (create if necessary)
|
139
|
+
git_call("push --set-upstream origin #{local_branch}", debug_mode, true)
|
140
|
+
create_pull_request(upstream)
|
141
|
+
# return to the user's original branch
|
142
|
+
# FIXME: keep track of original branch etc
|
143
|
+
git_call("checkout #{original_branch}")
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# delete obsolete branches (left over from already closed requests)
|
148
|
+
def clean(number=nil, force=false, all=false)
|
149
|
+
# pruning is needed to remove deleted branches from your local track
|
150
|
+
git_call('remote prune origin')
|
151
|
+
# determine strategy to clean.
|
152
|
+
if all
|
153
|
+
local.clean_all
|
154
|
+
else
|
155
|
+
local.clean_single(number, force)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Start a console session (used for debugging)
|
160
|
+
def console
|
161
|
+
puts 'Entering debug console.'
|
162
|
+
if RUBY_VERSION == '2.0.0'
|
163
|
+
require 'byebug'
|
164
|
+
byebug
|
165
|
+
else
|
166
|
+
require 'ruby-debug'
|
167
|
+
Debugger.start
|
168
|
+
debugger
|
169
|
+
end
|
170
|
+
puts 'Leaving debug console.'
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
def print_request(request)
|
176
|
+
date_string = format_time(request.updated_at)
|
177
|
+
comments_count = request.comments.to_i + request.review_comments.to_i
|
178
|
+
line = format_text(request.number, 8)
|
179
|
+
line << format_text(date_string, 11)
|
180
|
+
line << format_text(comments_count, 10)
|
181
|
+
line << format_text(request.title, 91)
|
182
|
+
puts line
|
183
|
+
end
|
184
|
+
|
185
|
+
def print_request_details(request)
|
186
|
+
comments_count = request.comments.to_i + request.review_comments.to_i
|
187
|
+
puts 'ID : ' + request.number.to_s
|
188
|
+
puts 'Label : ' + request.head.label
|
189
|
+
puts 'Updated : ' + format_time(request.updated_at)
|
190
|
+
puts 'Comments : ' + comments_count.to_s
|
191
|
+
puts
|
192
|
+
puts request.title
|
193
|
+
puts
|
194
|
+
unless request.body.empty?
|
195
|
+
puts request.body
|
196
|
+
puts
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def print_request_discussions(request)
|
201
|
+
puts 'Progress :'
|
202
|
+
puts
|
203
|
+
puts github.discussion(request.number)
|
204
|
+
end
|
205
|
+
|
206
|
+
# someone deleted the source repo
|
207
|
+
def print_repo_deleted(request)
|
208
|
+
user = request.head.user.login
|
209
|
+
url = request.patch_url
|
210
|
+
puts "Sorry, #{user} deleted the source repository."
|
211
|
+
puts "git-review doesn't support this."
|
212
|
+
puts "Tell the contributor not to do this."
|
213
|
+
puts
|
214
|
+
puts "You can still manually patch your repo by running:"
|
215
|
+
puts
|
216
|
+
puts " curl #{url} | git am"
|
217
|
+
puts
|
218
|
+
end
|
219
|
+
|
220
|
+
# ask for branch name if not provided
|
221
|
+
# @return [String] sanitized branch name
|
222
|
+
def get_branch_name
|
223
|
+
puts 'Please provide a name for the branch:'
|
224
|
+
branch_name = gets.chomp
|
225
|
+
branch_name.gsub(/\W+/, '_').downcase
|
226
|
+
end
|
227
|
+
|
228
|
+
# move uncommitted changes from target branch to local branch
|
229
|
+
# @return [String] the new local branch uncommitted changes are moved to
|
230
|
+
def move_uncommitted_changes(target_branch, new_branch)
|
231
|
+
new_branch ||= get_branch_name
|
232
|
+
local_branch = "review_#{Time.now.strftime("%y%m%d")}_#{new_branch}"
|
233
|
+
git_call("checkout -b #{local_branch}")
|
234
|
+
# make sure we are on the feature branch
|
235
|
+
if local.source_branch == local_branch
|
236
|
+
# stash any uncommitted changes
|
237
|
+
save_uncommitted_changes = local.uncommitted_changes?
|
238
|
+
git_call('stash') if save_uncommitted_changes
|
239
|
+
# go back to target and get rid of pending commits
|
240
|
+
git_call("checkout #{target_branch}")
|
241
|
+
git_call("reset --hard origin/#{target_branch}")
|
242
|
+
git_call("checkout #{local_branch}")
|
243
|
+
git_call('stash pop') if save_uncommitted_changes
|
244
|
+
local_branch
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def create_pull_request(to_upstream=false)
|
249
|
+
target_repo = local.target_repo(to_upstream)
|
250
|
+
head = local.head
|
251
|
+
base = local.target_branch
|
252
|
+
title, body = create_title_and_body(base)
|
253
|
+
|
254
|
+
# gather information before creating pull request
|
255
|
+
lastest_number = github.latest_request_number(target_repo)
|
256
|
+
|
257
|
+
# create the actual pull request
|
258
|
+
github.create_pull_request(target_repo, base, head, title, body)
|
259
|
+
# switch back to target_branch and check for success
|
260
|
+
git_call("checkout #{base}")
|
261
|
+
|
262
|
+
# make sure the new pull request is indeed created
|
263
|
+
new_number = github.request_number_by_title(title, target_repo)
|
264
|
+
if new_number && new_number > lastest_number
|
265
|
+
puts "Successfully created new request ##{new_number}"
|
266
|
+
puts "https://github.com/#{target_repo}/pull/#{new_number}"
|
267
|
+
else
|
268
|
+
puts "Pull request was not created for #{target_repo}."
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
|
273
|
+
# @return [Array(String, String)] the title and the body of pull request
|
274
|
+
def create_title_and_body(target_branch)
|
275
|
+
source = local.source
|
276
|
+
login = github.github.login
|
277
|
+
commits = git_call("log --format='%H' HEAD...#{target_branch}").
|
278
|
+
lines.count
|
279
|
+
puts "commits: #{commits}"
|
280
|
+
if commits == 1
|
281
|
+
# we can create a really specific title and body
|
282
|
+
title = git_call("log --format='%s' HEAD...#{target_branch}").chomp
|
283
|
+
body = git_call("log --format='%b' HEAD...#{target_branch}").chomp
|
284
|
+
else
|
285
|
+
title = "[Review] Request from '#{login}' @ '#{source}'"
|
286
|
+
body = "Please review the following changes:\n"
|
287
|
+
body += git_call("log --oneline HEAD...#{target_branch}").
|
288
|
+
lines.map{|l| " * #{l.chomp}"}.join("\n")
|
289
|
+
end
|
290
|
+
edit_title_and_body(title, body)
|
291
|
+
end
|
292
|
+
|
293
|
+
# TODO: refactor
|
294
|
+
def edit_title_and_body(title, body)
|
295
|
+
tmpfile = Tempfile.new('git-review')
|
296
|
+
tmpfile.write(title + "\n\n" + body)
|
297
|
+
tmpfile.flush
|
298
|
+
editor = ENV['TERM_EDITOR'] || ENV['EDITOR']
|
299
|
+
unless editor
|
300
|
+
warn 'Please set $EDITOR or $TERM_EDITOR in your .bash_profile.'
|
301
|
+
end
|
302
|
+
|
303
|
+
system("#{editor || 'open'} #{tmpfile.path}")
|
304
|
+
|
305
|
+
tmpfile.rewind
|
306
|
+
lines = tmpfile.read.lines.to_a
|
307
|
+
puts lines.inspect
|
308
|
+
title = lines.shift.chomp
|
309
|
+
lines.shift if lines[0].chomp.empty?
|
310
|
+
body = lines.join
|
311
|
+
tmpfile.unlink
|
312
|
+
[title, body]
|
313
|
+
end
|
314
|
+
|
315
|
+
def github
|
316
|
+
@github ||= ::GitReview::Github.instance
|
317
|
+
end
|
318
|
+
|
319
|
+
def local
|
320
|
+
@local ||= ::GitReview::Local.instance
|
321
|
+
end
|
322
|
+
|
323
|
+
def get_request_by_number(request_number)
|
324
|
+
request = github.request_exists?(request_number)
|
325
|
+
request || (raise ::GitReview::InvalidRequestIDError)
|
326
|
+
end
|
327
|
+
|
328
|
+
end
|
329
|
+
|
330
|
+
end
|
331
|
+
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module GitReview
|
2
|
+
|
3
|
+
class AuthenticationError < StandardError
|
4
|
+
def message
|
5
|
+
'You provided the wrong username/password, please try again.'
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class InvalidGitRepositoryError < StandardError
|
10
|
+
def message
|
11
|
+
"It is not a valid git repository or doesn't have a valid remote url."
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# A custom error to raise, if we know we can't go on.
|
16
|
+
class UnprocessableState < StandardError
|
17
|
+
def message
|
18
|
+
'Execution of git-review stopped.'
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class InvalidArgumentError < StandardError
|
23
|
+
def message
|
24
|
+
'Please specify valid arguments. See --help for more information.'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class InvalidRequestIDError < StandardError
|
29
|
+
def message
|
30
|
+
'Please specify a valid request ID.'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'net/https'
|
3
|
+
# Used to handle json data
|
4
|
+
require 'yajl'
|
5
|
+
# Required to hide password
|
6
|
+
require 'io/console'
|
7
|
+
# Required by yajl for decoding
|
8
|
+
require 'stringio'
|
9
|
+
# Used to retrieve hostname
|
10
|
+
require 'socket'
|
11
|
+
|
12
|
+
|
13
|
+
module GitReview
|
14
|
+
|
15
|
+
class Github
|
16
|
+
|
17
|
+
include ::GitReview::Internals
|
18
|
+
|
19
|
+
attr_reader :github
|
20
|
+
attr_accessor :source_repo
|
21
|
+
|
22
|
+
# acts like a singleton class but it's actually not
|
23
|
+
# use ::GitReview::Github.instance everywhere except in tests
|
24
|
+
def self.instance
|
25
|
+
@instance ||= new
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
configure_github_access
|
30
|
+
end
|
31
|
+
|
32
|
+
# setup connection with Github via OAuth
|
33
|
+
# @return [String] the username logged in
|
34
|
+
def configure_github_access
|
35
|
+
settings = ::GitReview::Settings.instance
|
36
|
+
if settings.oauth_token && settings.username
|
37
|
+
@github = Octokit::Client.new(
|
38
|
+
:login => settings.username,
|
39
|
+
:access_token => settings.oauth_token,
|
40
|
+
:auto_traversal => true
|
41
|
+
)
|
42
|
+
@github.login
|
43
|
+
else
|
44
|
+
configure_oauth
|
45
|
+
configure_github_access
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Boolean, Hash] the specified request if exists, otherwise false.
|
50
|
+
# Instead of true, the request itself is returned, so another round-trip
|
51
|
+
# of pull_request can be avoided.
|
52
|
+
def request_exists?(number, state='open')
|
53
|
+
return false if number.nil?
|
54
|
+
request = @github.pull_request(source_repo, number)
|
55
|
+
request.state == state ? request : false
|
56
|
+
rescue Octokit::NotFound
|
57
|
+
false
|
58
|
+
end
|
59
|
+
|
60
|
+
def request_exists_for_branch?(upstream=false, branch=local.source_branch)
|
61
|
+
target_repo = local.target_repo(upstream)
|
62
|
+
@github.pull_requests(target_repo).any? { |r|
|
63
|
+
r.head.ref == branch
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
# an alias to pull_requests
|
68
|
+
def current_requests(repo=source_repo)
|
69
|
+
@github.pull_requests(repo)
|
70
|
+
end
|
71
|
+
|
72
|
+
# a more detailed collection of requests
|
73
|
+
def current_requests_full(repo=source_repo)
|
74
|
+
@github.pull_requests(repo).collect { |request|
|
75
|
+
@github.pull_request(repo, request.number)
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
def update
|
80
|
+
git_call('fetch origin')
|
81
|
+
end
|
82
|
+
|
83
|
+
# @return [Array(String, String)] user and repo name from local git config
|
84
|
+
def repo_info_from_config
|
85
|
+
git_config = local.config
|
86
|
+
url = git_config['remote.origin.url']
|
87
|
+
raise ::GitReview::InvalidGitRepositoryError if url.nil?
|
88
|
+
|
89
|
+
user, project = github_url_matching(url)
|
90
|
+
# if there are no results yet, look for 'insteadof' substitutions
|
91
|
+
# in URL and try again
|
92
|
+
unless user && project
|
93
|
+
insteadof_url, true_url = github_insteadof_matching(git_config, url)
|
94
|
+
if insteadof_url and true_url
|
95
|
+
url = url.sub(insteadof_url, true_url)
|
96
|
+
user, project = github_url_matching(url)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
[user, project]
|
100
|
+
end
|
101
|
+
|
102
|
+
# @return [String] the source repo
|
103
|
+
def source_repo
|
104
|
+
# cache source_repo
|
105
|
+
if @source_repo
|
106
|
+
@source_repo
|
107
|
+
else
|
108
|
+
user, repo = repo_info_from_config
|
109
|
+
@source_repo = "#{user}/#{repo}" if user && repo
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def commit_discussion(number)
|
114
|
+
pull_commits = @github.pull_commits(source_repo, number)
|
115
|
+
repo = @github.pull_request(source_repo, number).head.repo.full_name
|
116
|
+
discussion = ["Commits on pull request:\n\n"]
|
117
|
+
discussion += pull_commits.collect { |commit|
|
118
|
+
# commit message
|
119
|
+
name = commit.committer.login
|
120
|
+
output = "\e[35m#{name}\e[m "
|
121
|
+
output << "committed \e[36m#{commit.sha[0..6]}\e[m "
|
122
|
+
output << "on #{format_time(commit.commit.committer.date)}"
|
123
|
+
output << ":\n#{''.rjust(output.length + 1, "-")}\n"
|
124
|
+
output << "#{commit.commit.message}"
|
125
|
+
output << "\n\n"
|
126
|
+
result = [output]
|
127
|
+
|
128
|
+
# comments on commit
|
129
|
+
comments = @github.commit_comments(repo, commit.sha)
|
130
|
+
result + comments.collect { |comment|
|
131
|
+
name = comment.user.login
|
132
|
+
output = "\e[35m#{name}\e[m "
|
133
|
+
output << "added a comment to \e[36m#{commit.sha[0..6]}\e[m"
|
134
|
+
output << " on #{format_time(comment.created_at)}"
|
135
|
+
unless comment.created_at == comment.updated_at
|
136
|
+
output << " (updated on #{format_time(comment.updated_at)})"
|
137
|
+
end
|
138
|
+
output << ":\n#{''.rjust(output.length + 1, "-")}\n"
|
139
|
+
output << comment.body
|
140
|
+
output << "\n\n"
|
141
|
+
}
|
142
|
+
}
|
143
|
+
discussion.compact.flatten unless discussion.empty?
|
144
|
+
end
|
145
|
+
|
146
|
+
def issue_discussion(number)
|
147
|
+
comments = @github.issue_comments(source_repo, number)
|
148
|
+
discussion = ["\nComments on pull request:\n\n"]
|
149
|
+
discussion += comments.collect { |comment|
|
150
|
+
name = comment.user.login
|
151
|
+
output = "\e[35m#{name}\e[m "
|
152
|
+
output << "added a comment to \e[36m#{comment.id}\e[m"
|
153
|
+
output << " on #{format_time(comment.created_at)}"
|
154
|
+
unless comment.created_at == comment.updated_at
|
155
|
+
output << " (updated on #{format_time(comment.updated_at)})"
|
156
|
+
end
|
157
|
+
output << ":\n#{''.rjust(output.length + 1, "-")}\n"
|
158
|
+
output << comment.body
|
159
|
+
output << "\n\n"
|
160
|
+
}
|
161
|
+
discussion.compact.flatten unless discussion.empty?
|
162
|
+
end
|
163
|
+
|
164
|
+
# show discussion for a request
|
165
|
+
def discussion(number)
|
166
|
+
commit_discussion(number) +
|
167
|
+
issue_discussion(number)
|
168
|
+
end
|
169
|
+
|
170
|
+
# show latest pull request number
|
171
|
+
def latest_request_number(repo=source_repo)
|
172
|
+
current_requests(repo).collect(&:number).sort.last.to_i
|
173
|
+
end
|
174
|
+
|
175
|
+
# get the number of the request that matches the title
|
176
|
+
def request_number_by_title(title, repo=source_repo)
|
177
|
+
request = current_requests(repo).find { |r| r.title == title }
|
178
|
+
request.number if request
|
179
|
+
end
|
180
|
+
|
181
|
+
# delegate methods that interact with Github to Octokit client
|
182
|
+
def method_missing(method, *args)
|
183
|
+
if @github.respond_to?(method)
|
184
|
+
@github.send(method, *args)
|
185
|
+
else
|
186
|
+
super
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def respond_to?(method)
|
191
|
+
@github.respond_to?(method) || super
|
192
|
+
end
|
193
|
+
|
194
|
+
private
|
195
|
+
|
196
|
+
def configure_oauth
|
197
|
+
begin
|
198
|
+
prepare_username_and_password
|
199
|
+
prepare_description
|
200
|
+
authorize
|
201
|
+
rescue ::GitReview::AuthenticationError => e
|
202
|
+
warn e.message
|
203
|
+
rescue ::GitReview::UnprocessableState => e
|
204
|
+
warn e.message
|
205
|
+
exit 1
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def prepare_username_and_password
|
210
|
+
puts "Requesting a OAuth token for git-review."
|
211
|
+
puts "This procedure will grant access to your public and private "\
|
212
|
+
"repositories."
|
213
|
+
puts "You can revoke this authorization by visiting the following page: "\
|
214
|
+
"https://github.com/settings/applications"
|
215
|
+
print "Please enter your GitHub's username: "
|
216
|
+
@username = STDIN.gets.chomp
|
217
|
+
print "Please enter your GitHub's password (it won't be stored anywhere): "
|
218
|
+
@password = STDIN.noecho(&:gets).chomp
|
219
|
+
print "\n"
|
220
|
+
end
|
221
|
+
|
222
|
+
def prepare_description(chosen_description=nil)
|
223
|
+
if chosen_description
|
224
|
+
@description = chosen_description
|
225
|
+
else
|
226
|
+
@description = "git-review - #{Socket.gethostname}"
|
227
|
+
puts "Please enter a description to associate to this token, it will "\
|
228
|
+
"make easier to find it inside of GitHub's application page."
|
229
|
+
puts "Press enter to accept the proposed description"
|
230
|
+
print "Description [#{@description}]:"
|
231
|
+
user_description = STDIN.gets.chomp
|
232
|
+
@description = user_description.empty? ? @description : user_description
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def authorize
|
237
|
+
uri = URI('https://api.github.com/authorizations')
|
238
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
239
|
+
http.use_ssl = true
|
240
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
241
|
+
req.basic_auth(@username, @password)
|
242
|
+
req.body = Yajl::Encoder.encode(
|
243
|
+
{
|
244
|
+
:scopes => %w(repo),
|
245
|
+
:note => @description
|
246
|
+
}
|
247
|
+
)
|
248
|
+
response = http.request(req)
|
249
|
+
if response.code == '201'
|
250
|
+
parser_response = Yajl::Parser.parse(response.body)
|
251
|
+
save_oauth_token(parser_response['token'])
|
252
|
+
elsif response.code == '401'
|
253
|
+
raise ::GitReview::AuthenticationError
|
254
|
+
else
|
255
|
+
raise ::GitReview::UnprocessableState, response.body
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
def save_oauth_token(token)
|
260
|
+
settings = ::GitReview::Settings.instance
|
261
|
+
settings.oauth_token = token
|
262
|
+
settings.username = @username
|
263
|
+
settings.save!
|
264
|
+
puts "OAuth token successfully created.\n"
|
265
|
+
end
|
266
|
+
|
267
|
+
# extract user and project name from GitHub URL.
|
268
|
+
def github_url_matching(url)
|
269
|
+
matches = /github\.com.(.*?)\/(.*)/.match(url)
|
270
|
+
matches ? [matches[1], matches[2].sub(/\.git\z/, '')] : [nil, nil]
|
271
|
+
end
|
272
|
+
|
273
|
+
# look for 'insteadof' substitutions in URL.
|
274
|
+
def github_insteadof_matching(config, url)
|
275
|
+
first_match = config.keys.collect { |key|
|
276
|
+
[config[key], /url\.(.*github\.com.*)\.insteadof/.match(key)]
|
277
|
+
}.find { |insteadof_url, true_url|
|
278
|
+
url.index(insteadof_url) and true_url != nil
|
279
|
+
}
|
280
|
+
first_match ? [first_match[0], first_match[1][1]] : [nil, nil]
|
281
|
+
end
|
282
|
+
|
283
|
+
def local
|
284
|
+
@local ||= ::GitReview::Local.instance
|
285
|
+
end
|
286
|
+
|
287
|
+
end
|
288
|
+
|
289
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module GitReview
|
2
|
+
|
3
|
+
module Internals
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
# System call to 'git'.
|
8
|
+
def git_call(command, verbose = debug_mode, enforce_success = false)
|
9
|
+
if verbose
|
10
|
+
puts
|
11
|
+
puts " git #{command}"
|
12
|
+
puts
|
13
|
+
end
|
14
|
+
output = `git #{command}`
|
15
|
+
puts output if verbose and not output.empty?
|
16
|
+
# If we need sth. to succeed, but it doesn't, then stop right there.
|
17
|
+
if enforce_success and not last_command_successful?
|
18
|
+
puts output unless output.empty?
|
19
|
+
raise ::GitReview::UnprocessableState
|
20
|
+
end
|
21
|
+
output
|
22
|
+
end
|
23
|
+
|
24
|
+
# @return [Boolean] whether the last issued system call was successful
|
25
|
+
def last_command_successful?
|
26
|
+
$?.exitstatus == 0
|
27
|
+
end
|
28
|
+
|
29
|
+
def debug_mode
|
30
|
+
::GitReview::Settings.instance.review_mode == 'debug'
|
31
|
+
end
|
32
|
+
|
33
|
+
# display helper to make output more configurable
|
34
|
+
def format_text(info, size)
|
35
|
+
info.to_s.gsub("\n", ' ')[0, size-1].ljust(size)
|
36
|
+
end
|
37
|
+
|
38
|
+
# display helper to unify time output
|
39
|
+
def format_time(time)
|
40
|
+
time = Time.parse(time) if time.is_a?(String)
|
41
|
+
time.strftime('%d-%b-%y')
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,227 @@
|
|
1
|
+
module GitReview
|
2
|
+
|
3
|
+
# The local repository is where the git-review command is being called
|
4
|
+
# by default. It is (supposedly) able to handle systems other than Github.
|
5
|
+
# TODO: remove Github-dependency
|
6
|
+
class Local
|
7
|
+
|
8
|
+
include ::GitReview::Internals
|
9
|
+
|
10
|
+
attr_accessor :config
|
11
|
+
|
12
|
+
# acts like a singleton class but it's actually not
|
13
|
+
# use ::GitReview::Local.instance everywhere except in tests
|
14
|
+
def self.instance
|
15
|
+
@instance ||= new
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
# find root git directory if currently in subdirectory
|
20
|
+
if git_call('rev-parse --show-toplevel').strip.empty?
|
21
|
+
raise ::GitReview::InvalidGitRepositoryError
|
22
|
+
else
|
23
|
+
add_pull_refspec
|
24
|
+
load_config
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# @return [Array<String>] all existing branches
|
29
|
+
def all_branches
|
30
|
+
git_call('branch -a').split("\n").collect { |s| s.strip }
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Array<String>] all open requests' branches shouldn't be deleted
|
34
|
+
def protected_branches
|
35
|
+
github.current_requests.collect { |r| r.head.ref }
|
36
|
+
end
|
37
|
+
|
38
|
+
# @return [Array<String>] all review branches with 'review_' prefix
|
39
|
+
def review_branches
|
40
|
+
all_branches.collect { |branch|
|
41
|
+
# only use uniq branch names (no matter if local or remote)
|
42
|
+
branch.split('/').last if branch.include?('review_')
|
43
|
+
}.compact.uniq
|
44
|
+
end
|
45
|
+
|
46
|
+
# clean a single request's obsolete branch
|
47
|
+
def clean_single(number, force=false)
|
48
|
+
request = github.pull_request(source_repo, number)
|
49
|
+
if request && request.state == 'closed'
|
50
|
+
# ensure there are no unmerged commits or '--force' flag has been set
|
51
|
+
branch_name = request.head.ref
|
52
|
+
if unmerged_commits?(branch_name) && !force
|
53
|
+
puts "Won't delete branches that contain unmerged commits."
|
54
|
+
puts "Use '--force' to override."
|
55
|
+
else
|
56
|
+
delete_branch(branch_name)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
rescue Octokit::NotFound
|
60
|
+
false
|
61
|
+
end
|
62
|
+
|
63
|
+
# clean all obsolete branches
|
64
|
+
def clean_all
|
65
|
+
(review_branches - protected_branches).each do |branch_name|
|
66
|
+
# only clean up obsolete branches.
|
67
|
+
delete_branch(branch_name) unless unmerged_commits?(branch_name, false)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# delete local and remote branches that match a given name
|
72
|
+
# @param branch_name [String] name of the branch to delete
|
73
|
+
def delete_branch(branch_name)
|
74
|
+
delete_local_branch(branch_name)
|
75
|
+
delete_remote_branch(branch_name)
|
76
|
+
end
|
77
|
+
|
78
|
+
# delete local branch if it exists.
|
79
|
+
# @param (see #delete_branch)
|
80
|
+
def delete_local_branch(branch_name)
|
81
|
+
if branch_exists?(:local, branch_name)
|
82
|
+
git_call("branch -D #{branch_name}", true)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# delete remote branch if it exists.
|
87
|
+
# @param (see #delete_branch)
|
88
|
+
def delete_remote_branch(branch_name)
|
89
|
+
if branch_exists?(:remote, branch_name)
|
90
|
+
git_call("push origin :#{branch_name}", true)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# @param location [Symbol] location of the branch, `:remote` or `:local`
|
95
|
+
# @param branch_name [String] name of the branch
|
96
|
+
# @return [Boolean] whether a branch exists in a specified location
|
97
|
+
def branch_exists?(location, branch_name)
|
98
|
+
return false unless [:remote, :local].include?(location)
|
99
|
+
prefix = location == :remote ? 'remotes/origin/' : ''
|
100
|
+
all_branches.include?(prefix + branch_name)
|
101
|
+
end
|
102
|
+
|
103
|
+
# @return [Boolean] whether there are local changes not committed
|
104
|
+
def uncommitted_changes?
|
105
|
+
!git_call('diff HEAD').empty?
|
106
|
+
end
|
107
|
+
|
108
|
+
# @param branch_name [String] name of the branch
|
109
|
+
# @param verbose [Boolean] if verbose output
|
110
|
+
# @return [Boolean] whether there are unmerged commits on the local or
|
111
|
+
# remote branch.
|
112
|
+
def unmerged_commits?(branch_name, verbose=true)
|
113
|
+
locations = []
|
114
|
+
locations << '' if branch_exists?(:local, branch_name)
|
115
|
+
locations << 'origin/' if branch_exists?(:remote, branch_name)
|
116
|
+
locations = locations.repeated_permutation(2).to_a
|
117
|
+
if locations.empty?
|
118
|
+
puts 'Nothing to do. All cleaned up already.' if verbose
|
119
|
+
return false
|
120
|
+
end
|
121
|
+
# compare remote and local branch with remote and local master
|
122
|
+
responses = locations.collect { |loc|
|
123
|
+
git_call "cherry #{loc.first}#{target_branch} #{loc.last}#{branch_name}"
|
124
|
+
}
|
125
|
+
# select commits (= non empty, not just an error message and not only
|
126
|
+
# duplicate commits staring with '-').
|
127
|
+
unmerged_commits = responses.reject { |response|
|
128
|
+
response.empty? or response.include?('fatal: Unknown commit') or
|
129
|
+
response.split("\n").reject { |x| x.index('-') == 0 }.empty?
|
130
|
+
}
|
131
|
+
# if the array ain't empty, we got unmerged commits
|
132
|
+
if unmerged_commits.empty?
|
133
|
+
false
|
134
|
+
else
|
135
|
+
puts "Unmerged commits on branch '#{branch_name}'."
|
136
|
+
true
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# @return [Boolean] whether a specified commit has already been merged.
|
141
|
+
def merged?(sha)
|
142
|
+
not git_call("rev-list #{sha} ^HEAD 2>&1").split("\n").size > 0
|
143
|
+
end
|
144
|
+
|
145
|
+
# @return [String] the source repo
|
146
|
+
def source_repo
|
147
|
+
github.source_repo
|
148
|
+
end
|
149
|
+
|
150
|
+
# @return [String] the current source branch
|
151
|
+
def source_branch
|
152
|
+
git_call('branch').chomp.match(/\*(.*)/)[0][2..-1]
|
153
|
+
end
|
154
|
+
|
155
|
+
# @return [String] combine source repo and branch
|
156
|
+
def source
|
157
|
+
"#{source_repo}/#{source_branch}"
|
158
|
+
end
|
159
|
+
|
160
|
+
# @return [String] the name of the target branch
|
161
|
+
def target_branch
|
162
|
+
# TODO: Manually override this and set arbitrary branches
|
163
|
+
ENV['TARGET_BRANCH'] || 'master'
|
164
|
+
end
|
165
|
+
|
166
|
+
# if to send a pull request to upstream repo, get the parent as target
|
167
|
+
# @return [String] the name of the target repo
|
168
|
+
def target_repo(upstream=false)
|
169
|
+
# TODO: Manually override this and set arbitrary repositories
|
170
|
+
if upstream
|
171
|
+
github.repository(source_repo).parent.full_name
|
172
|
+
else
|
173
|
+
source_repo
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# @return [String] combine target repo and branch
|
178
|
+
def target
|
179
|
+
"#{target_repo}/#{target_branch}"
|
180
|
+
end
|
181
|
+
|
182
|
+
# @return [String] the head string used for pull requests
|
183
|
+
def head
|
184
|
+
# in the form of 'user:branch'
|
185
|
+
"#{github.github.login}:#{source_branch}"
|
186
|
+
end
|
187
|
+
|
188
|
+
# @return [Boolean] whether already on a feature branch
|
189
|
+
def on_feature_branch?
|
190
|
+
# If current and target are the same, we are not on a feature branch.
|
191
|
+
# If they are different, but we are on master, we should still to switch
|
192
|
+
# to a separate branch (since master makes for a poor feature branch).
|
193
|
+
source_branch != target_branch && source_branch != 'master'
|
194
|
+
end
|
195
|
+
|
196
|
+
# add remote.origin.fetch to check out pull request locally
|
197
|
+
# see {https://help.github.com/articles/checking-out-pull-requests-locally}
|
198
|
+
def add_pull_refspec
|
199
|
+
refspec = '+refs/pull/*/head:refs/remotes/origin/pr/*'
|
200
|
+
fetch_config = "config --local --add remote.origin.fetch #{refspec}"
|
201
|
+
git_call(fetch_config, false) unless config_list.include?(refspec)
|
202
|
+
end
|
203
|
+
|
204
|
+
def load_config
|
205
|
+
@config = {}
|
206
|
+
config_list.split("\n").each do |line|
|
207
|
+
key, value = line.split(/=/, 2)
|
208
|
+
if @config[key] && @config[key] != value
|
209
|
+
@config[key] = [@config[key]].flatten << value
|
210
|
+
else
|
211
|
+
@config[key] = value
|
212
|
+
end
|
213
|
+
end
|
214
|
+
@config
|
215
|
+
end
|
216
|
+
|
217
|
+
def config_list
|
218
|
+
git_call('config --list', false)
|
219
|
+
end
|
220
|
+
|
221
|
+
def github
|
222
|
+
@github ||= ::GitReview::Github.instance
|
223
|
+
end
|
224
|
+
|
225
|
+
end
|
226
|
+
|
227
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
module GitReview
|
5
|
+
|
6
|
+
class Settings
|
7
|
+
|
8
|
+
# acts like a singleton class but it's actually not
|
9
|
+
# use ::GitReview::Settings.instance everywhere except in tests
|
10
|
+
def self.instance
|
11
|
+
@instance ||= new
|
12
|
+
end
|
13
|
+
|
14
|
+
# Read settings from ~/.git_review.yml upon initialization.
|
15
|
+
def initialize
|
16
|
+
@config_file = File.join(Dir.home, '.git_review.yml')
|
17
|
+
@config = YAML.load_file(@config_file) if File.exists?(@config_file)
|
18
|
+
@config ||= {}
|
19
|
+
end
|
20
|
+
|
21
|
+
# Write settings back to file.
|
22
|
+
def save!
|
23
|
+
File.open(@config_file, 'w') do |file|
|
24
|
+
file.write(YAML.dump(@config))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Allow to access config options.
|
29
|
+
def method_missing(method, *args)
|
30
|
+
# Determine whether to set or get an attribute.
|
31
|
+
if method.to_s =~ /(.*)=$/
|
32
|
+
@config[$1.to_sym] = args.shift
|
33
|
+
else
|
34
|
+
@config[method.to_sym]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def respond_to?(method)
|
39
|
+
if method.to_s =~ /(.*)=$/ || @config.keys.include?(method.to_sym)
|
40
|
+
true
|
41
|
+
else
|
42
|
+
super
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: git-review
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.1.
|
4
|
+
version: 1.1.7
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2013-
|
12
|
+
date: 2013-09-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: launchy
|
@@ -32,17 +32,17 @@ dependencies:
|
|
32
32
|
requirement: !ruby/object:Gem::Requirement
|
33
33
|
none: false
|
34
34
|
requirements:
|
35
|
-
- -
|
35
|
+
- - ~>
|
36
36
|
- !ruby/object:Gem::Version
|
37
|
-
version:
|
37
|
+
version: 1.24.0
|
38
38
|
type: :runtime
|
39
39
|
prerelease: false
|
40
40
|
version_requirements: !ruby/object:Gem::Requirement
|
41
41
|
none: false
|
42
42
|
requirements:
|
43
|
-
- -
|
43
|
+
- - ~>
|
44
44
|
- !ruby/object:Gem::Version
|
45
|
-
version:
|
45
|
+
version: 1.24.0
|
46
46
|
- !ruby/object:Gem::Dependency
|
47
47
|
name: yajl-ruby
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
@@ -85,6 +85,12 @@ files:
|
|
85
85
|
- LICENSE
|
86
86
|
- lib/settings.rb
|
87
87
|
- lib/git-review.rb
|
88
|
+
- lib/git-review/settings.rb
|
89
|
+
- lib/git-review/commands.rb
|
90
|
+
- lib/git-review/errors.rb
|
91
|
+
- lib/git-review/github.rb
|
92
|
+
- lib/git-review/internals.rb
|
93
|
+
- lib/git-review/local.rb
|
88
94
|
- lib/oauth_helper.rb
|
89
95
|
- bin/git-review
|
90
96
|
homepage: http://github.com/b4mboo/git-review
|
@@ -112,3 +118,4 @@ signing_key:
|
|
112
118
|
specification_version: 3
|
113
119
|
summary: facilitates GitHub code reviews
|
114
120
|
test_files: []
|
121
|
+
has_rdoc:
|