git-review 2.0.0.alpha → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,6 +6,12 @@ module GitReview
6
6
  end
7
7
  end
8
8
 
9
+ class InvalidGitProviderError < StandardError
10
+ def message
11
+ "It is not a valid git provider."
12
+ end
13
+ end
14
+
9
15
  class InvalidGitRepositoryError < StandardError
10
16
  def message
11
17
  "It is not a valid git repository or doesn't have a valid remote url."
@@ -0,0 +1,38 @@
1
+ module GitReview
2
+
3
+ module Helpers
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
+
15
+ output = `git #{command}`
16
+ puts output if verbose and not output.empty?
17
+
18
+ if enforce_success and not command_successful?
19
+ puts output unless output.empty?
20
+ raise ::GitReview::UnprocessableState
21
+ end
22
+
23
+ output
24
+ end
25
+
26
+ # @return [Boolean] Whether the last issued system call was successful
27
+ def command_successful?
28
+ $?.exitstatus == 0
29
+ end
30
+
31
+ # @return [Boolean] Whether we are running in debugging moder or not
32
+ def debug_mode
33
+ ::GitReview::Settings.instance.review_mode == 'debug'
34
+ end
35
+
36
+ end
37
+
38
+ end
@@ -5,7 +5,7 @@ module GitReview
5
5
  # TODO: remove Github-dependency
6
6
  class Local
7
7
 
8
- include ::GitReview::Internals
8
+ include ::GitReview::Helpers
9
9
 
10
10
  attr_accessor :config
11
11
 
@@ -20,32 +20,115 @@ module GitReview
20
20
  if git_call('rev-parse --show-toplevel').strip.empty?
21
21
  raise ::GitReview::InvalidGitRepositoryError
22
22
  else
23
- add_pull_refspec
24
23
  load_config
25
24
  end
26
25
  end
27
26
 
27
+ # List all available remotes.
28
+ def remotes
29
+ git_call('remote').split("\n")
30
+ end
31
+
32
+ # Determine whether a remote with a given name exists?
33
+ def remote_exists?(name)
34
+ remotes.include? name
35
+ end
36
+
37
+ # Create a Hash with all remotes as keys and their urls as values.
38
+ def remotes_with_urls
39
+ result = {}
40
+ git_call('remote -vv').split("\n").each do |line|
41
+ entries = line.split("\t")
42
+ remote = entries.first
43
+ target_entry = entries.last.split(' ')
44
+ direction = target_entry.last[1..-2].to_sym
45
+ target_url = target_entry.first
46
+ result[remote] ||= {}
47
+ result[remote][direction] = target_url
48
+ end
49
+ result
50
+ end
51
+
52
+ # Collect all remotes for a given url.
53
+ def remotes_for_url(remote_url)
54
+ result = remotes_with_urls.collect do |remote, urls|
55
+ remote if urls.values.all? { |url| url == remote_url }
56
+ end
57
+ result.compact
58
+ end
59
+
60
+ # Find or create the correct remote for a fork with a given owner name.
61
+ def remote_for_request(request)
62
+ repo_owner = request.head.repo.owner.login
63
+ remote_url = server.remote_url_for(repo_owner)
64
+ remotes = remotes_for_url(remote_url)
65
+ if remotes.empty?
66
+ remote = "review_#{repo_owner}"
67
+ git_call("remote add #{remote} #{remote_url}", debug_mode, true)
68
+ else
69
+ remote = remotes.first
70
+ end
71
+ remote
72
+ end
73
+
74
+ # Remove obsolete remotes with review prefix.
75
+ def clean_remotes
76
+ protected_remotes = remotes_for_branches
77
+ remotes.each do |remote|
78
+ # Only remove review remotes that aren't referenced by current branches.
79
+ if remote.index('review_') == 0 && !protected_remotes.include?(remote)
80
+ git_call "remote remove #{remote}"
81
+ end
82
+ end
83
+ end
84
+
85
+ # Prune all configured remotes.
86
+ def prune_remotes
87
+ remotes.each { |remote| git_call "remote prune #{remote}" }
88
+ end
89
+
90
+ # Find all remotes which are currently referenced by local branches.
91
+ def remotes_for_branches
92
+ remotes = git_call('branch -lvv').gsub('* ', '').split("\n").map do |line|
93
+ line.split(' ')[2][1..-2].split('/').first
94
+ end
95
+ remotes.uniq
96
+ end
97
+
98
+ # Finds the correct remote for a given branch name.
99
+ def remote_for_branch(branch_name)
100
+ git_call('branch -lvv').gsub('* ', '').split("\n").each do |line|
101
+ entries = line.split(' ')
102
+ next unless entries.first == branch_name
103
+ # Return the remote name or nil for local branches.
104
+ match = entries[2].match(%r(\[(.*)(\]|:)))
105
+ return match[1].split('/').first if match
106
+ end
107
+ nil
108
+ end
109
+
28
110
  # @return [Array<String>] all existing branches
29
111
  def all_branches
30
- git_call('branch -a').split("\n").collect { |s| s.strip }
112
+ git_call('branch -a').gsub('* ', '').split("\n").collect { |s| s.strip }
31
113
  end
32
114
 
33
115
  # @return [Array<String>] all open requests' branches shouldn't be deleted
34
116
  def protected_branches
35
- github.current_requests.collect { |r| r.head.ref }
117
+ server.current_requests.collect { |r| r.head.ref }
36
118
  end
37
119
 
38
120
  # @return [Array<String>] all review branches with 'review_' prefix
39
121
  def review_branches
40
- all_branches.collect { |branch|
122
+ all_branches.collect { |entry|
41
123
  # only use uniq branch names (no matter if local or remote)
42
- branch.split('/').last if branch.include?('review_')
124
+ branch_name = entry.split('/').last
125
+ branch_name if branch_name.index('review_') == 0
43
126
  }.compact.uniq
44
127
  end
45
128
 
46
129
  # clean a single request's obsolete branch
47
- def clean_single(number, force=false)
48
- request = github.pull_request(source_repo, number)
130
+ def clean_single(number, force = false)
131
+ request = server.pull_request(source_repo, number)
49
132
  if request && request.state == 'closed'
50
133
  # ensure there are no unmerged commits or '--force' flag has been set
51
134
  branch_name = request.head.ref
@@ -137,14 +220,30 @@ module GitReview
137
220
  end
138
221
  end
139
222
 
223
+ # @return [Boolean] whether there are commits not in target branch yet
224
+ def new_commits?(upstream = false)
225
+ # Check if an upstream remote exists and create it if necessary.
226
+ remote_url = server.remote_url_for(*target_repo(upstream).split('/'))
227
+ remote = remotes_for_url(remote_url).first
228
+ unless remote
229
+ remote = 'upstream'
230
+ git_call "remote add #{remote} #{remote_url}"
231
+ end
232
+ git_call "fetch #{remote}"
233
+ target = upstream ? "#{remote}/#{target_branch}" : target_branch
234
+ not git_call("cherry #{target}").empty?
235
+ end
236
+
140
237
  # @return [Boolean] whether a specified commit has already been merged.
141
238
  def merged?(sha)
142
- not git_call("rev-list #{sha} ^HEAD 2>&1").split("\n").size > 0
239
+ branches = git_call("branch --contains #{sha} 2>&1").split("\n").
240
+ collect { |b| b.delete('*').strip }
241
+ branches.include?(target_branch)
143
242
  end
144
243
 
145
244
  # @return [String] the source repo
146
245
  def source_repo
147
- github.source_repo
246
+ server.source_repo
148
247
  end
149
248
 
150
249
  # @return [String] the current source branch
@@ -168,7 +267,7 @@ module GitReview
168
267
  def target_repo(upstream=false)
169
268
  # TODO: Manually override this and set arbitrary repositories
170
269
  if upstream
171
- github.repository(source_repo).parent.full_name
270
+ server.repository(source_repo).parent.full_name
172
271
  else
173
272
  source_repo
174
273
  end
@@ -182,7 +281,7 @@ module GitReview
182
281
  # @return [String] the head string used for pull requests
183
282
  def head
184
283
  # in the form of 'user:branch'
185
- "#{github.github.login}:#{source_branch}"
284
+ "#{source_repo.split('/').first}:#{source_branch}"
186
285
  end
187
286
 
188
287
  # @return [Boolean] whether already on a feature branch
@@ -193,12 +292,9 @@ module GitReview
193
292
  source_branch != target_branch && source_branch != 'master'
194
293
  end
195
294
 
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)
295
+ # Remove all non word characters and turn them into underscores.
296
+ def sanitize_branch_name(name)
297
+ name.gsub(/\W+/, '_').downcase
202
298
  end
203
299
 
204
300
  def load_config
@@ -218,8 +314,49 @@ module GitReview
218
314
  git_call('config --list', false)
219
315
  end
220
316
 
221
- def github
222
- @github ||= ::GitReview::Github.instance
317
+ def server
318
+ @server ||= ::GitReview::Server.instance
319
+ end
320
+
321
+ # @return [Array(String, String)] the title and the body of pull request
322
+ def create_title_and_body(target_branch)
323
+ login = server.login
324
+ commits = git_call("log --format='%H' HEAD...#{target_branch}").
325
+ lines.count
326
+ puts "Commits: #{commits}"
327
+ if commits == 1
328
+ # we can create a really specific title and body
329
+ title = git_call("log --format='%s' HEAD...#{target_branch}").chomp
330
+ body = git_call("log --format='%b' HEAD...#{target_branch}").chomp
331
+ else
332
+ title = "[Review] Request from '#{login}' @ '#{source}'"
333
+ body = "Please review the following changes:\n"
334
+ body += git_call("log --oneline HEAD...#{target_branch}").
335
+ lines.map{|l| " * #{l.chomp}"}.join("\n")
336
+ end
337
+ edit_title_and_body(title, body)
338
+ end
339
+
340
+ # TODO: refactor
341
+ def edit_title_and_body(title, body)
342
+ tmpfile = Tempfile.new('git-review')
343
+ tmpfile.write(title + "\n\n" + body)
344
+ tmpfile.flush
345
+ editor = ENV['TERM_EDITOR'] || ENV['EDITOR']
346
+ unless editor
347
+ warn 'Please set $EDITOR or $TERM_EDITOR in your .bash_profile.'
348
+ end
349
+
350
+ system("#{editor || 'open'} #{tmpfile.path}")
351
+
352
+ tmpfile.rewind
353
+ lines = tmpfile.read.lines.to_a
354
+ #puts lines.inspect
355
+ title = lines.shift.chomp
356
+ lines.shift if lines[0].chomp.empty?
357
+ body = lines.join
358
+ tmpfile.unlink
359
+ [title, body]
223
360
  end
224
361
 
225
362
  end
@@ -0,0 +1,85 @@
1
+ module GitReview
2
+
3
+ module Provider
4
+
5
+ class Base
6
+
7
+ attr_reader :client
8
+ attr_accessor :source_repo
9
+
10
+ def self.instance
11
+ @instance ||= new
12
+ end
13
+
14
+ def initialize
15
+ configure_access
16
+ end
17
+
18
+ def update
19
+ git_call('fetch origin')
20
+ end
21
+
22
+ # @return [String] Source repo name
23
+ def source_repo
24
+ @source_repo ||= begin
25
+ user, repo = repo_info_from_config
26
+ "#{user}/#{repo}"
27
+ end
28
+ end
29
+
30
+ # @return [String] Current username
31
+ def login
32
+ settings.username
33
+ end
34
+
35
+ # @return [Array(String, String)] User and repo name from git
36
+ def repo_info_from_config
37
+ url = local.config['remote.origin.url']
38
+ raise ::GitReview::InvalidGitRepositoryError if url.nil?
39
+
40
+ user, project = url_matching(url)
41
+
42
+ unless user && project
43
+ insteadof_url, true_url = insteadof_matching(local.config, url)
44
+
45
+ if insteadof_url and true_url
46
+ url = url.sub(insteadof_url, true_url)
47
+ user, project = url_matching(url)
48
+ end
49
+ end
50
+
51
+ [user, project]
52
+ end
53
+
54
+ # Ensure we find the right request
55
+ def get_request_by_number(request_number)
56
+ request_exists?(request_number) || (raise ::GitReview::InvalidRequestIDError)
57
+ end
58
+
59
+ def method_missing(method, *args)
60
+ if client.respond_to?(method)
61
+ client.send(method, *args)
62
+ else
63
+ super
64
+ end
65
+ end
66
+
67
+ def respond_to?(method)
68
+ client.respond_to?(method) || super
69
+ end
70
+
71
+ private
72
+
73
+ def local
74
+ @local ||= ::GitReview::Local.instance
75
+ end
76
+
77
+ def settings
78
+ @settings ||= ::GitReview::Settings.instance
79
+ end
80
+
81
+ end
82
+
83
+ end
84
+
85
+ end
@@ -0,0 +1,25 @@
1
+ module GitReview
2
+
3
+ module Provider
4
+
5
+ class Bitbucket < Base
6
+
7
+ include ::GitReview::Helpers
8
+
9
+ attr_reader :bitbucket
10
+
11
+ def configure_access
12
+ end
13
+
14
+ def source_repo
15
+ end
16
+
17
+ def update
18
+ git_call('fetch origin')
19
+ end
20
+
21
+ end
22
+
23
+ end
24
+
25
+ end
@@ -0,0 +1,271 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ require 'yajl'
4
+ require 'io/console'
5
+ require 'stringio'
6
+ require 'socket'
7
+
8
+ module GitReview
9
+
10
+ module Provider
11
+
12
+ class Github < Base
13
+
14
+ include ::GitReview::Helpers
15
+
16
+ # @return [String] Authenticated username
17
+ def configure_access
18
+ if settings.oauth_token && settings.username
19
+ @client = Octokit::Client.new(
20
+ login: settings.username,
21
+ access_token: settings.oauth_token,
22
+ auto_traversal: true
23
+ )
24
+
25
+ @client.login
26
+ else
27
+ configure_oauth
28
+ configure_access
29
+ end
30
+ end
31
+
32
+ # @return [Boolean, Hash] the specified request if exists, otherwise false.
33
+ # Instead of true, the request itself is returned, so another round-trip
34
+ # of pull_request can be avoided.
35
+ def request_exists?(number, state='open')
36
+ return false if number.nil?
37
+ request = client.pull_request(source_repo, number)
38
+ request.state == state ? request : false
39
+ rescue Octokit::NotFound
40
+ false
41
+ end
42
+
43
+ def request_exists_for_branch?(upstream = false, branch = local.source_branch)
44
+ target_repo = local.target_repo(upstream)
45
+ client.pull_requests(target_repo).any? { |r| r.head.ref == branch }
46
+ end
47
+
48
+ # an alias to pull_requests
49
+ def current_requests(repo=source_repo)
50
+ client.pull_requests(repo)
51
+ end
52
+
53
+ # a more detailed collection of requests
54
+ def current_requests_full(repo=source_repo)
55
+ threads = []
56
+ requests = []
57
+ client.pull_requests(repo).each do |req|
58
+ threads << Thread.new {
59
+ requests << client.pull_request(repo, req.number)
60
+ }
61
+ end
62
+ threads.each { |t| t.join }
63
+ requests
64
+ end
65
+
66
+ def send_pull_request(to_upstream = false)
67
+ target_repo = local.target_repo(to_upstream)
68
+ head = local.head
69
+ base = local.target_branch
70
+ title, body = local.create_title_and_body(base)
71
+
72
+ # gather information before creating pull request
73
+ latest_number = latest_request_number(target_repo)
74
+
75
+ # create the actual pull request
76
+ create_pull_request(target_repo, base, head, title, body)
77
+ # switch back to target_branch and check for success
78
+ git_call "checkout #{base}"
79
+
80
+ # make sure the new pull request is indeed created
81
+ new_number = request_number_by_title(title, target_repo)
82
+ if new_number && new_number > latest_number
83
+ puts "Successfully created new request ##{new_number}"
84
+ puts request_url_for target_repo, new_number
85
+ else
86
+ puts "Pull request was not created for #{target_repo}."
87
+ end
88
+ end
89
+
90
+ def commit_discussion(number)
91
+ pull_commits = client.pull_commits(source_repo, number)
92
+ repo = client.pull_request(source_repo, number).head.repo.full_name
93
+ discussion = ["Commits on pull request:\n\n"]
94
+ discussion += pull_commits.collect { |commit|
95
+ # commit message
96
+ name = commit.committer.login
97
+ output = "\e[35m#{name}\e[m "
98
+ output << "committed \e[36m#{commit.sha[0..6]}\e[m "
99
+ output << "on #{commit.commit.committer.date.review_time}"
100
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n"
101
+ output << "#{commit.commit.message}"
102
+ output << "\n\n"
103
+ result = [output]
104
+
105
+ # comments on commit
106
+ comments = client.commit_comments(repo, commit.sha)
107
+ result + comments.collect { |comment|
108
+ name = comment.user.login
109
+ output = "\e[35m#{name}\e[m "
110
+ output << "added a comment to \e[36m#{commit.sha[0..6]}\e[m"
111
+ output << " on #{comment.created_at.review_time}"
112
+ unless comment.created_at == comment.updated_at
113
+ output << " (updated on #{comment.updated_at.review_time})"
114
+ end
115
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n"
116
+ output << comment.body
117
+ output << "\n\n"
118
+ }
119
+ }
120
+ discussion.compact.flatten unless discussion.empty?
121
+ end
122
+
123
+ def issue_discussion(number)
124
+ comments = client.issue_comments(source_repo, number) +
125
+ client.review_comments(source_repo, number)
126
+ discussion = ["\nComments on pull request:\n\n"]
127
+ discussion += comments.collect { |comment|
128
+ name = comment.user.login
129
+ output = "\e[35m#{name}\e[m "
130
+ output << "added a comment to \e[36m#{comment.id}\e[m"
131
+ output << " on #{comment.created_at.review_time}"
132
+ unless comment.created_at == comment.updated_at
133
+ output << " (updated on #{comment.updated_at.review_time})"
134
+ end
135
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n"
136
+ output << comment.body
137
+ output << "\n\n"
138
+ }
139
+ discussion.compact.flatten unless discussion.empty?
140
+ end
141
+
142
+ # get the number of comments, including comments on commits
143
+ def comments_count(request)
144
+ issue_c = request.comments + request.review_comments
145
+ commits_c = client.pull_commits(source_repo, request.number).
146
+ inject(0) { |sum, c| sum + c.commit.comment_count }
147
+ issue_c + commits_c
148
+ end
149
+
150
+ # show discussion for a request
151
+ def discussion(number)
152
+ commit_discussion(number) +
153
+ issue_discussion(number)
154
+ end
155
+
156
+ # show latest pull request number
157
+ def latest_request_number(repo=source_repo)
158
+ current_requests(repo).collect(&:number).sort.last.to_i
159
+ end
160
+
161
+ # get the number of the request that matches the title
162
+ def request_number_by_title(title, repo=source_repo)
163
+ request = current_requests(repo).find { |r| r.title == title }
164
+ request.number if request
165
+ end
166
+
167
+ # FIXME: Remove this method after merging create_pull_request from commands.rb, currently no specs
168
+ def request_url_for(target_repo, request_number)
169
+ "https://github.com/#{target_repo}/pull/#{request_number}"
170
+ end
171
+
172
+ # FIXME: Needs to be moved into Server class, as its result is dependent of
173
+ # the actual provider (i.e. GitHub or BitBucket).
174
+ def remote_url_for(user_name, repo_name = repo_info_from_config.last)
175
+ "git@github.com:#{user_name}/#{repo_name}.git"
176
+ end
177
+
178
+ private
179
+
180
+ def configure_oauth
181
+ begin
182
+ prepare_username_and_password
183
+ prepare_description
184
+ authorize
185
+ rescue ::GitReview::AuthenticationError => e
186
+ warn e.message
187
+ rescue ::GitReview::UnprocessableState => e
188
+ warn e.message
189
+ exit 1
190
+ end
191
+ end
192
+
193
+ def prepare_username_and_password
194
+ puts "Requesting a OAuth token for git-review."
195
+ puts "This procedure will grant access to your public and private "\
196
+ "repositories."
197
+ puts "You can revoke this authorization by visiting the following page: "\
198
+ "https://github.com/settings/applications"
199
+ print "Please enter your GitHub's username: "
200
+ @username = STDIN.gets.chomp
201
+ print "Please enter your GitHub's password (it won't be stored anywhere): "
202
+ @password = STDIN.noecho(&:gets).chomp
203
+ print "\n"
204
+ end
205
+
206
+ def prepare_description(chosen_description=nil)
207
+ if chosen_description
208
+ @description = chosen_description
209
+ else
210
+ @description = "git-review - #{Socket.gethostname}"
211
+ puts "Please enter a description to associate to this token, it will "\
212
+ "make easier to find it inside of GitHub's application page."
213
+ puts "Press enter to accept the proposed description"
214
+ print "Description [#{@description}]:"
215
+ user_description = STDIN.gets.chomp
216
+ @description = user_description.empty? ? @description : user_description
217
+ end
218
+ end
219
+
220
+ def authorize
221
+ uri = URI('https://api.github.com/authorizations')
222
+ http = Net::HTTP.new(uri.host, uri.port)
223
+ http.use_ssl = true
224
+ req = Net::HTTP::Post.new(uri.request_uri)
225
+ req.basic_auth(@username, @password)
226
+ req.body = Yajl::Encoder.encode(
227
+ {
228
+ scopes: %w(repo),
229
+ note: @description
230
+ }
231
+ )
232
+ response = http.request(req)
233
+ if response.code == '201'
234
+ parser_response = Yajl::Parser.parse(response.body)
235
+ save_oauth_token(parser_response['token'])
236
+ elsif response.code == '401'
237
+ raise ::GitReview::AuthenticationError
238
+ else
239
+ raise ::GitReview::UnprocessableState, response.body
240
+ end
241
+ end
242
+
243
+ def save_oauth_token(token)
244
+ settings = ::GitReview::Settings.instance
245
+ settings.oauth_token = token
246
+ settings.username = @username
247
+ settings.save!
248
+ puts "OAuth token successfully created.\n"
249
+ end
250
+
251
+ # extract user and project name from GitHub URL.
252
+ def url_matching(url)
253
+ matches = /github\.com.(.*?)\/(.*)/.match(url)
254
+ matches ? [matches[1], matches[2].sub(/\.git\z/, '')] : [nil, nil]
255
+ end
256
+
257
+ # look for 'insteadof' substitutions in URL.
258
+ def insteadof_matching(config, url)
259
+ first_match = config.keys.collect { |key|
260
+ [config[key], /url\.(.*github\.com.*)\.insteadof/.match(key)]
261
+ }.find { |insteadof_url, true_url|
262
+ url.index(insteadof_url) and true_url != nil
263
+ }
264
+ first_match ? [first_match[0], first_match[1][1]] : [nil, nil]
265
+ end
266
+
267
+ end
268
+
269
+ end
270
+
271
+ end