git-review 2.0.0.alpha → 2.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.
@@ -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