git-review 1.1.3 → 1.1.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (2) hide show
  1. data/lib/git-review.rb +124 -49
  2. metadata +23 -7
@@ -4,6 +4,9 @@ require 'octokit'
4
4
  require 'launchy'
5
5
  # Time is used to parse time strings from git back into Time objects.
6
6
  require 'time'
7
+ # tempfile is used to create a temporary file containing PR's title and body.
8
+ # This file is going to be edited by the system editor.
9
+ require 'tempfile'
7
10
 
8
11
  # A custom error to raise, if we know we can't go on.
9
12
  class UnprocessableState < StandardError
@@ -16,27 +19,30 @@ class GitReview
16
19
 
17
20
  # List all pending requests.
18
21
  def list
19
- @current_requests.reverse! if @args.shift == '--reverse'
20
- output = @current_requests.collect do |pending_request|
21
- # Find only pending (= unmerged) requests and output summary. GitHub might
22
- # still think of them as pending, as it doesn't know about local changes.
23
- next if merged?(pending_request['head']['sha'])
24
- line = format_text(pending_request['number'], 8)
25
- date_string = format_time(pending_request['updated_at'])
22
+ output = @current_requests.collect do |request|
23
+ details = @github.pull_request(source_repo, request.number)
24
+ # Find only pending (= unmerged) requests and output summary.
25
+ # Explicitly look for local changes (that GitHub does not yet know about).
26
+ next if merged?(request.head.sha)
27
+ line = format_text(request.number, 8)
28
+ date_string = format_time(request.updated_at)
26
29
  line << format_text(date_string, 11)
27
- line << format_text(pending_request['comments'], 10)
28
- line << format_text(pending_request['title'], 91)
30
+ line << format_text(details.comments + details.review_comments, 10)
31
+ line << format_text(request.title, 91)
29
32
  line
30
33
  end
31
- if output.compact.empty?
32
- puts "No pending requests for '#{source}'"
33
- return
34
+ output.compact!
35
+ if output.empty?
36
+ puts "No pending requests for '#{source}'."
37
+ else
38
+ puts "Pending requests for '#{source}':"
39
+ puts 'ID Updated Comments Title'
40
+ output.reverse! if @args.shift == '--reverse'
41
+ output.each { |line| puts line }
34
42
  end
35
- puts "Pending requests for '#{source}'"
36
- puts 'ID Updated Comments Title'
37
- puts output.compact
38
43
  end
39
44
 
45
+
40
46
  # Show details for a single request.
41
47
  def show
42
48
  return unless request_exists?
@@ -58,11 +64,13 @@ class GitReview
58
64
  discussion
59
65
  end
60
66
 
67
+
61
68
  # Open a browser window and review a specified request.
62
69
  def browse
63
70
  Launchy.open(@current_request['html_url']) if request_exists?
64
71
  end
65
72
 
73
+
66
74
  # Checkout a specified request's changes to your local repository.
67
75
  def checkout
68
76
  return unless request_exists?
@@ -75,6 +83,7 @@ class GitReview
75
83
  git_call "checkout #{create_local_branch}#{@current_request['head']['ref']}"
76
84
  end
77
85
 
86
+
78
87
  # Accept a specified request by merging it into master.
79
88
  def merge
80
89
  return unless request_exists?
@@ -104,6 +113,7 @@ class GitReview
104
113
  puts git_call(exec_cmd)
105
114
  end
106
115
 
116
+
107
117
  # Add an approving comment to the request.
108
118
  def approve
109
119
  return unless request_exists?
@@ -116,6 +126,7 @@ class GitReview
116
126
  end
117
127
  end
118
128
 
129
+
119
130
  # Close a specified request.
120
131
  def close
121
132
  return unless request_exists?
@@ -123,6 +134,7 @@ class GitReview
123
134
  puts 'Successfully closed request.' unless request_exists?('open', @current_request['number'])
124
135
  end
125
136
 
137
+
126
138
  # Prepare local repository to create a new request.
127
139
  # Sets @local_branch.
128
140
  def prepare
@@ -154,6 +166,7 @@ class GitReview
154
166
  end
155
167
  end
156
168
 
169
+
157
170
  # Create a new request.
158
171
  # TODO: Support creating requests to other repositories and branches (like the original repo, this has been forked from).
159
172
  def create
@@ -168,18 +181,17 @@ class GitReview
168
181
  # Push latest commits to the remote branch (and by that, create it if necessary).
169
182
  git_call "push --set-upstream origin #{@local_branch}", debug_mode, true
170
183
  # Gather information.
171
- last_request_id = @current_requests.collect{|req| req['number'] }.sort.last.to_i
172
- title = "[Review] Request from '#{git_config['github.login']}' @ '#{source}'"
173
- # TODO: Insert commit messages (that are not yet in master) into body (since this will be displayed inside the mail that is sent out).
174
- body = 'Please review the following changes:'
184
+ last_request_id = @current_requests.collect { |req| req['number'] }.sort.last.to_i
185
+ title, body = create_title_and_body(target_branch)
175
186
  # Create the actual pull request.
176
187
  @github.create_pull_request target_repo, target_branch, source_branch, title, body
177
188
  # Switch back to target_branch and check for success.
178
189
  git_call "checkout #{target_branch}"
179
190
  update
180
- potential_new_request = @current_requests.find{ |req| req['title'] == title }
191
+ potential_new_request = @current_requests.find { |req| req['title'] == title }
181
192
  if potential_new_request and potential_new_request['number'] > last_request_id
182
- puts "Successfully created new request ##{potential_new_request['number']}."
193
+ puts "Successfully created new request ##{potential_new_request['number']}" \
194
+ "(#{File.join("https://github.com", target_repo, "pull", potential_new_request['number'].to_s)})."
183
195
  end
184
196
  # Return to the user's original branch.
185
197
  git_call "checkout #{@original_branch}"
@@ -188,10 +200,11 @@ class GitReview
188
200
  end
189
201
  end
190
202
 
203
+
191
204
  # Deletes obsolete branches (left over from already closed requests).
192
205
  def clean
193
206
  # Pruning is needed to remove already deleted branches from your local track.
194
- git_call "remote prune origin"
207
+ git_call 'remote prune origin'
195
208
  # Determine strategy to clean.
196
209
  case @args.size
197
210
  when 0
@@ -212,6 +225,7 @@ class GitReview
212
225
  end
213
226
  end
214
227
 
228
+
215
229
  # Start a console session (used for debugging).
216
230
  def console
217
231
  puts 'Entering debug console.'
@@ -226,13 +240,12 @@ class GitReview
226
240
  private
227
241
 
228
242
  # Setup variables and call actual commands.
229
- def initialize(args)
243
+ def initialize(args = [])
244
+ @args = args
230
245
  command = args.shift
231
246
  if command and self.respond_to?(command)
232
247
  @user, @repo = repo_info
233
- return if @user.nil? or @repo.nil?
234
- @args = args
235
- return unless configure_github_access
248
+ return unless @user && @repo && configure_github_access
236
249
  update unless command == 'clean'
237
250
  self.send command
238
251
  else
@@ -245,6 +258,7 @@ class GitReview
245
258
  puts 'Execution of git-review command stopped.'
246
259
  end
247
260
 
261
+
248
262
  # Show a quick reference of available commands.
249
263
  def help
250
264
  puts 'Usage: git review <command>'
@@ -261,9 +275,10 @@ class GitReview
261
275
  puts ' prepare Creates a new local branch for a request.'
262
276
  puts ' create Create a new request.'
263
277
  puts ' clean <ID> [--force] Delete a request\'s remote and local branches.'
264
- puts ' clean --all? Delete all obsolete branches.'
278
+ puts ' clean --all Delete all obsolete branches.'
265
279
  end
266
280
 
281
+
267
282
  # Check existence of specified request and assign @current_request.
268
283
  def request_exists?(state = 'open', request_id = nil)
269
284
  # NOTE: If request_id is set explicitly we might need to update to get the
@@ -275,7 +290,7 @@ class GitReview
275
290
  puts 'Please specify a valid ID.'
276
291
  return false
277
292
  end
278
- @current_request = @current_requests.find{ |req| req['number'] == request_id }
293
+ @current_request = @current_requests.find { |req| req['number'] == request_id }
279
294
  unless @current_request
280
295
  # Additional try to get an older request from Github by specifying the number.
281
296
  request = @github.pull_request source_repo, request_id
@@ -290,18 +305,20 @@ class GitReview
290
305
  end
291
306
  end
292
307
 
308
+
293
309
  # Get latest changes from GitHub.
294
310
  def update(state = 'open')
295
- @current_requests = @github.pull_requests source_repo, state
296
- repos = @current_requests.collect do |req|
297
- repo = req['head']['repository']
298
- "#{repo['owner']}/#{repo['name']}" unless repo.nil?
311
+ @current_requests = @github.pull_requests(source_repo, state)
312
+ repos = @current_requests.collect do |request|
313
+ repo = request.head.repository
314
+ "#{repo.owner}/#{repo.name}" if repo
299
315
  end
300
316
  repos.uniq.compact.each do |repo|
301
- git_call("fetch git@github.com:#{repo}.git +refs/heads/*:refs/pr/#{repo}/*")
317
+ git_call "fetch git@github.com:#{repo}.git +refs/heads/*:refs/pr/#{repo}/*"
302
318
  end
303
319
  end
304
320
 
321
+
305
322
  # Cleans a single request's obsolete branches.
306
323
  def clean_single(force_deletion = false)
307
324
  update('closed')
@@ -314,15 +331,16 @@ class GitReview
314
331
  delete_branch(branch_name)
315
332
  end
316
333
 
334
+
317
335
  # Cleans all obsolete branches.
318
336
  def clean_all
319
337
  update
320
338
  # Protect all open requests' branches from deletion.
321
- protected_branches = @current_requests.collect{|request| request['head']['ref']}
339
+ protected_branches = @current_requests.collect { |request| request['head']['ref'] }
322
340
  # Select all branches with the correct prefix.
323
- review_branches = all_branches.select{|branch| branch.include?('review_')}
341
+ review_branches = all_branches.select { |branch| branch.include?('review_') }
324
342
  # Only use uniq branch names (no matter if local or remote).
325
- review_branches.collect{|branch| branch.split('/').last}.uniq.each do |branch_name|
343
+ review_branches.collect { |branch| branch.split('/').last }.uniq.each do |branch_name|
326
344
  # Only clean up obsolete branches.
327
345
  unless protected_branches.include?(branch_name) or unmerged_commits?(branch_name, false)
328
346
  delete_branch(branch_name)
@@ -330,6 +348,7 @@ class GitReview
330
348
  end
331
349
  end
332
350
 
351
+
333
352
  # Delete local and remote branches that match a given name.
334
353
  def delete_branch(branch_name)
335
354
  # Delete local branch if it exists.
@@ -338,6 +357,7 @@ class GitReview
338
357
  git_call("push origin :#{branch_name}", true) if branch_exists?(:remote, branch_name)
339
358
  end
340
359
 
360
+
341
361
  # Returns a boolean stating whether there are unmerged commits on the local or remote branch.
342
362
  def unmerged_commits?(branch_name, verbose = true)
343
363
  locations = []
@@ -354,7 +374,7 @@ class GitReview
354
374
  end
355
375
  # Select commits (= non empty, not just an error message and not only duplicate commits staring with '-').
356
376
  unmerged_commits = responses.reject do |response|
357
- response.empty? or response.include?('fatal: Unknown commit') or response.split("\n").reject{|x| x.index('-') == 0}.empty?
377
+ response.empty? or response.include?('fatal: Unknown commit') or response.split("\n").reject { |x| x.index('-') == 0 }.empty?
358
378
  end
359
379
  # If the array ain't empty, we got unmerged commits.
360
380
  if unmerged_commits.empty?
@@ -365,6 +385,7 @@ class GitReview
365
385
  end
366
386
  end
367
387
 
388
+
368
389
  # Returns a boolean stating whether a branch exists in a specified location.
369
390
  def branch_exists?(location, branch_name)
370
391
  return false unless [:remote, :local].include? location
@@ -372,6 +393,7 @@ class GitReview
372
393
  all_branches.include?(prefix + branch_name)
373
394
  end
374
395
 
396
+
375
397
  # System call to 'git'.
376
398
  def git_call(command, verbose = debug_mode, enforce_success = false)
377
399
  if verbose
@@ -389,6 +411,7 @@ class GitReview
389
411
  output
390
412
  end
391
413
 
414
+
392
415
  # Show current discussion for @current_request.
393
416
  def discussion
394
417
  request = @github.pull_request source_repo, @current_request['number']
@@ -404,7 +427,7 @@ class GitReview
404
427
  when "IssueComment", "CommitComment", "PullRequestReviewComment"
405
428
  output << "added a comment"
406
429
  output << " to \e[36m#{entry['commit_id'][0..6]}\e[m" if entry['commit_id']
407
- output << " on #{format_time(entry['created_at'])}"
430
+ output << " on #{format_time(entry['created_at'])}"
408
431
  unless entry['created_at'] == entry['updated_at']
409
432
  output << " (updated on #{format_time(entry['updated_at'])})"
410
433
  end
@@ -413,75 +436,86 @@ class GitReview
413
436
  output << entry['body']
414
437
  # Commits:
415
438
  when "Commit"
416
- output << "authored commit \e[36m#{entry['id'][0..6]}\e[m on #{format_time(entry['authored_date'])}"
417
- unless entry['authored_date'] == entry['committed_date']
418
- output << " (committed on #{format_time(entry['committed_date'])})"
419
- end
420
- output << ":\n#{''.rjust(output.length + 1, "-")}\n#{entry["message"]}"
439
+ output << "authored commit \e[36m#{entry['id'][0..6]}\e[m on #{format_time(entry['authored_date'])}"
440
+ unless entry['authored_date'] == entry['committed_date']
441
+ output << " (committed on #{format_time(entry['committed_date'])})"
442
+ end
443
+ output << ":\n#{''.rjust(output.length + 1, "-")}\n#{entry["message"]}"
421
444
  end
422
445
  output << "\n\n\n"
423
446
  end
424
447
  puts result.compact unless result.empty?
425
448
  end
426
449
 
450
+
427
451
  # Display helper to make output more configurable.
428
452
  def format_text(info, size)
429
453
  info.to_s.gsub("\n", ' ')[0, size-1].ljust(size)
430
454
  end
431
455
 
456
+
432
457
  # Display helper to unify time output.
433
458
  def format_time(time_string)
434
459
  Time.parse(time_string).strftime('%d-%b-%y')
435
460
  end
436
461
 
462
+
437
463
  # Returns a string that specifies the source repo.
438
464
  def source_repo
439
465
  "#{@user}/#{@repo}"
440
466
  end
441
467
 
468
+
442
469
  # Returns a string that specifies the source branch.
443
470
  def source_branch
444
471
  git_call('branch').chomp!.match(/\*(.*)/)[0][2..-1]
445
472
  end
446
473
 
474
+
447
475
  # Returns a string consisting of source repo and branch.
448
476
  def source
449
477
  "#{source_repo}/#{source_branch}"
450
478
  end
451
479
 
480
+
452
481
  # Returns a string that specifies the target repo.
453
482
  def target_repo
454
483
  # TODO: Enable possibility to manually override this and set arbitrary repositories.
455
484
  source_repo
456
485
  end
457
486
 
487
+
458
488
  # Returns a string that specifies the target branch.
459
489
  def target_branch
460
490
  # TODO: Enable possibility to manually override this and set arbitrary branches.
461
491
  ENV['TARGET_BRANCH'] || 'master'
462
492
  end
463
493
 
494
+
464
495
  # Returns a string consisting of target repo and branch.
465
496
  def target
466
497
  "#{target_repo}/#{target_branch}"
467
498
  end
468
499
 
500
+
469
501
  # Returns an Array of all existing branches.
470
502
  def all_branches
471
- @branches ||= git_call('branch -a').split("\n").collect{|s|s.strip}
503
+ @branches ||= git_call('branch -a').split("\n").collect { |s| s.strip }
472
504
  end
473
505
 
506
+
474
507
  # Returns a boolean stating whether a specified commit has already been merged.
475
508
  def merged?(sha)
476
509
  not git_call("rev-list #{sha} ^HEAD 2>&1").split("\n").size > 0
477
510
  end
478
511
 
512
+
479
513
  # Uses Octokit to access GitHub.
480
514
  def configure_github_access
481
515
  if git_config['github.login'] and git_config['github.password']
482
516
  @github = Octokit::Client.new(
483
- :login => git_config['github.login'],
484
- :password => git_config['github.password']
517
+ :login => git_config['github.login'],
518
+ :password => git_config['github.password']
485
519
  )
486
520
  true
487
521
  @github.login
@@ -495,16 +529,18 @@ class GitReview
495
529
  end
496
530
  end
497
531
 
532
+
498
533
  def debug_mode
499
534
  git_config['review.mode'] == 'debug'
500
535
  end
501
536
 
537
+
502
538
  # Collect git config information in a Hash for easy access.
503
539
  # Checks '~/.gitconfig' for credentials.
504
540
  def git_config
505
541
  unless @git_config
506
542
  # Read @git_config from local git config.
507
- @git_config = {}
543
+ @git_config = { }
508
544
  config_list = git_call('config --list', false)
509
545
  config_list.split("\n").each do |line|
510
546
  key, value = line.split('=')
@@ -514,6 +550,7 @@ class GitReview
514
550
  @git_config
515
551
  end
516
552
 
553
+
517
554
  # Returns an array consisting of information on the user and the project.
518
555
  def repo_info
519
556
  # Extract user and project name from GitHub URL.
@@ -524,7 +561,7 @@ class GitReview
524
561
  end
525
562
  user, project = github_user_and_project(url)
526
563
  # If there are no results yet, look for 'insteadof' substitutions in URL and try again.
527
- unless (user and project)
564
+ unless user && project
528
565
  short, base = github_insteadof_matching(config_hash, url)
529
566
  if short and base
530
567
  url = url.sub(short, base)
@@ -534,9 +571,10 @@ class GitReview
534
571
  [user, project]
535
572
  end
536
573
 
574
+
537
575
  # Looks for 'insteadof' substitutions in URL.
538
576
  def github_insteadof_matching(config_hash, url)
539
- first = config_hash.collect { |key,value|
577
+ first = config_hash.collect { |key, value|
540
578
  [value, /url\.(.*github\.com.*)\.insteadof/.match(key)]
541
579
  }.find { |value, match|
542
580
  url.index(value) and match != nil
@@ -544,15 +582,52 @@ class GitReview
544
582
  first ? [first[0], first[1][1]] : [nil, nil]
545
583
  end
546
584
 
585
+
547
586
  # Extract user and project name from GitHub URL.
548
587
  def github_user_and_project(github_url)
549
588
  matches = /github\.com.(.*?)\/(.*)/.match(github_url)
550
589
  matches ? [matches[1], matches[2].sub(/\.git\z/, '')] : [nil, nil]
551
590
  end
552
591
 
592
+
553
593
  # Returns a boolean stating whether the last issued system call was successful.
554
594
  def last_command_successful?
555
595
  $?.exitstatus == 0
556
596
  end
557
597
 
598
+ # Returns an array where the 1st item is the title and the 2nd one is the body
599
+ def create_title_and_body(target_branch)
600
+ commits = git_call("log --format='%H' HEAD...#{target_branch}").lines.count
601
+ puts "commits: #{commits}"
602
+ if commits == 1
603
+ # we can create a really specific title and body
604
+ title = git_call("log --format='%s' HEAD...#{target_branch}").chomp
605
+ body = git_call("log --format='%b' HEAD...#{target_branch}").chomp
606
+ else
607
+ title = "[Review] Request from '#{git_config['github.login']}' @ '#{source}'"
608
+ body = "Please review the following changes:\n"
609
+ body += git_call("log --oneline HEAD...#{target_branch}").lines.map{|l| " * #{l.chomp}"}.join("\n")
610
+ end
611
+
612
+ tmpfile = Tempfile.new('git-review')
613
+ tmpfile.write(title + "\n\n" + body)
614
+ tmpfile.flush
615
+ editor = ENV['TERM_EDITOR'] || ENV['EDITOR']
616
+ warn "Please set $EDITOR or $TERM_EDITOR in your .bash_profile." unless editor
617
+
618
+ system("#{editor || 'open'} #{tmpfile.path}")
619
+
620
+ tmpfile.rewind
621
+ lines = tmpfile.read.lines.to_a
622
+ puts lines.inspect
623
+ title = lines.shift.chomp
624
+ lines.shift if lines[0].chomp.empty?
625
+
626
+ body = lines.join
627
+
628
+ tmpfile.unlink
629
+
630
+ [title, body]
631
+ end
632
+
558
633
  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.3
4
+ version: 1.1.5
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: 2012-06-30 00:00:00.000000000 Z
12
+ date: 2013-02-08 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: 1.7.0
37
+ version: '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: 1.7.0
45
+ version: '0'
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: yajl-ruby
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -59,6 +59,22 @@ dependencies:
59
59
  - - ! '>='
60
60
  - !ruby/object:Gem::Version
61
61
  version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: rspec
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
62
78
  description: Manage review workflow for projects hosted on GitHub (using pull requests).
63
79
  email: bamberger.dominik@gmail.com
64
80
  executables:
@@ -89,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
105
  version: '0'
90
106
  requirements: []
91
107
  rubyforge_project:
92
- rubygems_version: 1.8.24
108
+ rubygems_version: 1.8.23
93
109
  signing_key:
94
110
  specification_version: 3
95
111
  summary: facilitates GitHub code reviews