hub 1.10.6 → 1.11.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of hub might be problematic. Click here for more details.

data/lib/hub/context.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require 'shellwords'
2
2
  require 'forwardable'
3
- require 'uri'
3
+ require 'delegate'
4
4
 
5
5
  module Hub
6
6
  # Methods for inspecting the environment, such as reading git config,
@@ -99,7 +99,7 @@ module Hub
99
99
 
100
100
  repo_methods = [
101
101
  :current_branch,
102
- :current_project, :upstream_project,
102
+ :remote_branch_and_project,
103
103
  :repo_owner, :repo_host,
104
104
  :remotes, :remotes_group, :origin_remote
105
105
  ]
@@ -141,15 +141,13 @@ module Hub
141
141
  remote = origin_remote and remote.project
142
142
  end
143
143
 
144
- def upstream_project
145
- if branch = current_branch and upstream = branch.upstream and upstream.remote?
146
- remote = remote_by_name upstream.remote_name
147
- remote.project
144
+ def remote_branch_and_project(username_fetcher)
145
+ project = main_project
146
+ if project and branch = current_branch
147
+ branch = branch.push_target(username_fetcher.call(project.host))
148
+ project = remote_by_name(branch.remote_name).project if branch && branch.remote?
148
149
  end
149
- end
150
-
151
- def current_project
152
- upstream_project || main_project
150
+ [branch, project]
153
151
  end
154
152
 
155
153
  def current_branch
@@ -159,19 +157,31 @@ module Hub
159
157
  end
160
158
 
161
159
  def master_branch
162
- Branch.new self, 'refs/heads/master'
160
+ if remote = origin_remote
161
+ default_branch = git_command("rev-parse --symbolic-full-name #{remote}")
162
+ end
163
+ Branch.new(self, default_branch || 'refs/heads/master')
163
164
  end
164
165
 
166
+ ORIGIN_NAMES = %w[ upstream github origin ]
167
+
165
168
  def remotes
166
169
  @remotes ||= begin
167
170
  # TODO: is there a plumbing command to get a list of remotes?
168
171
  list = git_command('remote').to_s.split("\n")
169
- # force "origin" to be first in the list
170
- main = list.delete('origin') and list.unshift(main)
172
+ list = ORIGIN_NAMES.inject([]) { |sorted, name|
173
+ sorted << list.delete(name)
174
+ }.compact.concat(list)
171
175
  list.map { |name| Remote.new self, name }
172
176
  end
173
177
  end
174
178
 
179
+ def remotes_for_publish(owner_name)
180
+ list = ORIGIN_NAMES.map {|n| remote_by_name(n) }
181
+ list << remotes.find {|r| p = r.project and p.owner == owner_name }
182
+ list.compact.uniq.reverse
183
+ end
184
+
175
185
  def remotes_group(name)
176
186
  git_config "remotes.#{name}"
177
187
  end
@@ -269,7 +279,7 @@ module Hub
269
279
  end
270
280
  end
271
281
 
272
- class GithubURL < URI::HTTPS
282
+ class GithubURL < DelegateClass(URI::HTTP)
273
283
  extend Forwardable
274
284
 
275
285
  attr_reader :project
@@ -279,16 +289,15 @@ module Hub
279
289
  def self.resolve(url, local_repo)
280
290
  u = URI(url)
281
291
  if %[http https].include? u.scheme and project = GithubProject.from_url(u, local_repo)
282
- self.new(u.scheme, u.userinfo, u.host, u.port, u.registry,
283
- u.path, u.opaque, u.query, u.fragment, project)
292
+ self.new(u, project)
284
293
  end
285
294
  rescue URI::InvalidURIError
286
295
  nil
287
296
  end
288
297
 
289
- def initialize(*args)
290
- @project = args.pop
291
- super(*args)
298
+ def initialize(uri, project)
299
+ @project = project
300
+ super(uri)
292
301
  end
293
302
 
294
303
  # segment of path after the project owner and name
@@ -305,7 +314,10 @@ module Hub
305
314
  end
306
315
 
307
316
  def master?
308
- short_name == 'master'
317
+ master_name = if local_repo then local_repo.master_branch.short_name
318
+ else 'master'
319
+ end
320
+ short_name == master_name
309
321
  end
310
322
 
311
323
  def upstream
@@ -314,6 +326,21 @@ module Hub
314
326
  end
315
327
  end
316
328
 
329
+ def push_target(owner_name)
330
+ push_default = local_repo.git_config('push.default')
331
+ if %w[upstream tracking].include?(push_default)
332
+ upstream
333
+ else
334
+ short = short_name
335
+ refs = local_repo.remotes_for_publish(owner_name).map { |remote|
336
+ "refs/remotes/#{remote}/#{short}"
337
+ }
338
+ if branch = refs.detect {|ref| local_repo.git_command("rev-parse -q --verify #{ref}") }
339
+ Branch.new(local_repo, branch)
340
+ end
341
+ end
342
+ end
343
+
317
344
  def remote?
318
345
  name.index('refs/remotes/') == 0
319
346
  end
@@ -431,6 +458,7 @@ module Hub
431
458
  def git_editor
432
459
  # possible: ~/bin/vi, $SOME_ENVIRONMENT_VARIABLE, "C:\Program Files\Vim\gvim.exe" --nofork
433
460
  editor = git_command 'var GIT_EDITOR'
461
+ editor.gsub!(/\$(\w+|\{\w+\})/) { ENV[$1.tr('{}', '')] }
434
462
  editor = ENV[$1] if editor =~ /^\$(\w+)$/
435
463
  editor = File.expand_path editor if (editor =~ /^[~.]/ or editor.index('/')) and editor !~ /["']/
436
464
  # avoid shellsplitting "C:\Program Files"
@@ -463,6 +491,11 @@ module Hub
463
491
  RbConfig::CONFIG['host_os'] =~ /msdos|mswin|djgpp|mingw|windows/
464
492
  end
465
493
 
494
+ def unix?
495
+ require 'rbconfig'
496
+ RbConfig::CONFIG['host_os'] =~ /(aix|darwin|linux|(net|free|open)bsd|cygwin|solaris|irix|hpux)/i
497
+ end
498
+
466
499
  # Cross-platform way of finding an executable in the $PATH.
467
500
  #
468
501
  # which('ruby') #=> /usr/bin/ruby
@@ -489,6 +522,16 @@ module Hub
489
522
  def tmp_dir
490
523
  ENV['TMPDIR'] || ENV['TEMP'] || '/tmp'
491
524
  end
525
+
526
+ def terminal_width
527
+ if unix?
528
+ width = %x{stty size 2>#{NULL}}.split[1].to_i
529
+ width = %x{tput cols 2>#{NULL}}.to_i if width.zero?
530
+ else
531
+ width = 0
532
+ end
533
+ width < 10 ? 78 : width
534
+ end
492
535
  end
493
536
 
494
537
  include System
@@ -1,7 +1,4 @@
1
- require 'uri'
2
- require 'yaml'
3
1
  require 'forwardable'
4
- require 'fileutils'
5
2
 
6
3
  module Hub
7
4
  # Client for the GitHub v3 API.
@@ -15,7 +12,7 @@ module Hub
15
12
  # config_file = ENV['HUB_CONFIG'] || '~/.config/hub'
16
13
  # file_store = GitHubAPI::FileStore.new File.expand_path(config_file)
17
14
  # file_config = GitHubAPI::Configuration.new file_store
18
- # GitHubAPI.new file_config, :app_url => 'http://defunkt.io/hub/'
15
+ # GitHubAPI.new file_config, :app_url => 'http://hub.github.com/'
19
16
  # end
20
17
  class GitHubAPI
21
18
  attr_reader :config, :oauth_app_url
@@ -25,7 +22,6 @@ module Hub
25
22
  # Options:
26
23
  # - config: an object that implements:
27
24
  # - username(host)
28
- # - api_token(host, user)
29
25
  # - password(host, user)
30
26
  # - oauth_token(host, user)
31
27
  def initialize config, options
@@ -46,6 +42,19 @@ module Hub
46
42
  'github.com' == host ? 'api.github.com' : host
47
43
  end
48
44
 
45
+ def username_via_auth_dance host
46
+ host = api_host(host)
47
+ config.username(host) do
48
+ if block_given?
49
+ yield
50
+ else
51
+ res = get("https://%s/user" % host)
52
+ res.error! unless res.success?
53
+ config.value_to_persist(res.data['login'])
54
+ end
55
+ end
56
+ end
57
+
49
58
  # Public: Fetch data for a specific repo.
50
59
  def repo_info project
51
60
  get "https://%s/repos/%s/%s" %
@@ -66,7 +75,7 @@ module Hub
66
75
 
67
76
  # Public: Create a new project.
68
77
  def create_repo project, options = {}
69
- is_org = project.owner.downcase != config.username(api_host(project.host)).downcase
78
+ is_org = project.owner.downcase != username_via_auth_dance(project.host).downcase
70
79
  params = { :name => project.name, :private => !!options[:private] }
71
80
  params[:description] = options[:description] if options[:description]
72
81
  params[:homepage] = options[:homepage] if options[:homepage]
@@ -88,6 +97,38 @@ module Hub
88
97
  res.data
89
98
  end
90
99
 
100
+ # Public: Fetch a pull request's patch
101
+ def pullrequest_patch project, pull_id
102
+ res = get "https://%s/repos/%s/%s/pulls/%d" %
103
+ [api_host(project.host), project.owner, project.name, pull_id] do |req|
104
+ req["Accept"] = "application/vnd.github.v3.patch"
105
+ end
106
+ res.error! unless res.success?
107
+ res.body
108
+ end
109
+
110
+ # Public: Fetch the patch from a commit
111
+ def commit_patch project, sha
112
+ res = get "https://%s/repos/%s/%s/commits/%s" %
113
+ [api_host(project.host), project.owner, project.name, sha] do |req|
114
+ req["Accept"] = "application/vnd.github.v3.patch"
115
+ end
116
+ res.error! unless res.success?
117
+ res.body
118
+ end
119
+
120
+ # Public: Fetch the first raw blob from a gist
121
+ def gist_raw gist_id
122
+ res = get("https://%s/gists/%s" % [api_host('github.com'), gist_id])
123
+ res.error! unless res.success?
124
+ raw_url = res.data['files'].values.first['raw_url']
125
+ res = get(raw_url) do |req|
126
+ req['Accept'] = 'text/plain'
127
+ end
128
+ res.error! unless res.success?
129
+ res.body
130
+ end
131
+
91
132
  # Returns parsed data from the new pull request.
92
133
  def create_pullrequest options
93
134
  project = options.fetch(:project)
@@ -110,12 +151,19 @@ module Hub
110
151
  res.data
111
152
  end
112
153
 
154
+ def statuses project, sha
155
+ res = get "https://%s/repos/%s/%s/statuses/%s" %
156
+ [api_host(project.host), project.owner, project.name, sha]
157
+
158
+ res.error! unless res.success?
159
+ res.data
160
+ end
161
+
113
162
  # Methods for performing HTTP requests
114
163
  #
115
164
  # Requires access to a `config` object that implements:
116
165
  # - proxy_uri(with_ssl)
117
166
  # - username(host)
118
- # - update_username(host, old_username, new_username)
119
167
  # - password(host, user)
120
168
  module HttpMethods
121
169
  # Decorator for Net::HTTPResponse
@@ -180,6 +228,7 @@ module Hub
180
228
  req['User-Agent'] = "Hub #{Hub::VERSION}"
181
229
  apply_authentication(req, url)
182
230
  yield req if block_given?
231
+ finalize_request(req, url)
183
232
 
184
233
  begin
185
234
  res = http.start { http.request(req) }
@@ -192,7 +241,7 @@ module Hub
192
241
 
193
242
  def request_uri url
194
243
  str = url.request_uri
195
- str = '/api/v3' << str if url.host != 'api.github.com'
244
+ str = '/api/v3' << str if url.host != 'api.github.com' && url.host != 'gist.github.com'
196
245
  str
197
246
  end
198
247
 
@@ -208,11 +257,17 @@ module Hub
208
257
  end
209
258
 
210
259
  def apply_authentication req, url
211
- user = url.user || config.username(url.host)
260
+ user = url.user ? CGI.unescape(url.user) : config.username(url.host)
212
261
  pass = config.password(url.host, user)
213
262
  req.basic_auth user, pass
214
263
  end
215
264
 
265
+ def finalize_request(req, url)
266
+ if !req['Accept'] || req['Accept'] == '*/*'
267
+ req['Accept'] = 'application/vnd.github.v3+json'
268
+ end
269
+ end
270
+
216
271
  def create_connection url
217
272
  use_ssl = 'https' == url.scheme
218
273
 
@@ -220,7 +275,6 @@ module Hub
220
275
  if proxy = config.proxy_uri(use_ssl)
221
276
  proxy_args << proxy.host << proxy.port
222
277
  if proxy.userinfo
223
- require 'cgi'
224
278
  # proxy user + password
225
279
  proxy_args.concat proxy.userinfo.split(':', 2).map {|a| CGI.unescape a }
226
280
  end
@@ -238,44 +292,59 @@ module Hub
238
292
 
239
293
  module OAuth
240
294
  def apply_authentication req, url
241
- if (req.path =~ /\/authorizations$/)
295
+ if req.path =~ %r{^(/api/v3)?/authorizations$}
242
296
  super
243
297
  else
244
- refresh = false
245
- user = url.user || config.username(url.host)
298
+ user = url.user ? CGI.unescape(url.user) : config.username(url.host)
246
299
  token = config.oauth_token(url.host, user) {
247
- refresh = true
248
300
  obtain_oauth_token url.host, user
249
301
  }
250
- if refresh
251
- # get current user info user to persist correctly capitalized login name
252
- res = get "https://#{url.host}/user"
253
- res.error! unless res.success?
254
- config.update_username(url.host, user, res.data['login'])
255
- end
256
302
  req['Authorization'] = "token #{token}"
257
303
  end
258
304
  end
259
305
 
260
- def obtain_oauth_token host, user
306
+ def obtain_oauth_token host, user, two_factor_code = nil
307
+ auth_url = URI.parse("https://%s@%s/authorizations" % [CGI.escape(user), host])
308
+
309
+ # dummy request to trigger a 2FA SMS since a HTTP GET won't do it
310
+ post(auth_url) if !two_factor_code
311
+
261
312
  # first try to fetch existing authorization
262
- res = get "https://#{user}@#{host}/authorizations"
263
- res.error! unless res.success?
313
+ res = get(auth_url) do |req|
314
+ req['X-GitHub-OTP'] = two_factor_code if two_factor_code
315
+ end
316
+ unless res.success?
317
+ if !two_factor_code && res['X-GitHub-OTP'].to_s.include?('required')
318
+ two_factor_code = config.prompt_auth_code
319
+ return obtain_oauth_token(host, user, two_factor_code)
320
+ else
321
+ res.error!
322
+ end
323
+ end
264
324
 
265
325
  if found = res.data.find {|auth| auth['app']['url'] == oauth_app_url }
266
326
  found['token']
267
327
  else
268
328
  # create a new authorization
269
- res = post "https://#{user}@#{host}/authorizations",
270
- :scopes => %w[repo], :note => 'hub', :note_url => oauth_app_url
329
+ res = post auth_url,
330
+ :scopes => %w[repo], :note => 'hub', :note_url => oauth_app_url do |req|
331
+ req['X-GitHub-OTP'] = two_factor_code if two_factor_code
332
+ end
271
333
  res.error! unless res.success?
272
334
  res.data['token']
273
335
  end
274
336
  end
275
337
  end
276
338
 
339
+ module GistAuth
340
+ def apply_authentication(req, url)
341
+ super unless url.host == 'gist.github.com'
342
+ end
343
+ end
344
+
277
345
  include HttpMethods
278
346
  include OAuth
347
+ include GistAuth
279
348
 
280
349
  # Filesystem store suitable for Configuration
281
350
  class FileStore
@@ -286,47 +355,83 @@ module Hub
286
355
  def initialize filename
287
356
  @filename = filename
288
357
  @data = Hash.new {|d, host| d[host] = [] }
358
+ @persist_next_change = false
289
359
  load if File.exist? filename
290
360
  end
291
361
 
292
- def fetch_user host
293
- unless entry = get(host).first
294
- user = yield
295
- # FIXME: more elegant handling of empty strings
296
- return nil if user.nil? or user.empty?
297
- entry = entry_for_user(host, user)
298
- end
299
- entry['user']
300
- end
301
-
302
362
  def fetch_value host, user, key
303
- entry = entry_for_user host, user
304
- entry[key.to_s] || begin
363
+ entries = get(host)
364
+ entries << {} if entries.empty?
365
+ entry = entries.first
366
+ entry.fetch(key.to_s) {
305
367
  value = yield
306
- if value and !value.empty?
307
- entry[key.to_s] = value
308
- save
309
- value
310
- else
311
- raise "no value"
312
- end
313
- end
368
+ raise "no value for key :#{key}" if value.nil? || value.empty?
369
+ entry[key.to_s] = value
370
+ save_if_needed
371
+ value
372
+ }
314
373
  end
315
374
 
316
- def entry_for_user host, username
317
- entries = get(host)
318
- entries.find {|e| e['user'] == username } or
319
- (entries << {'user' => username}).last
375
+ def persist_next_change!
376
+ @persist_next_change = true
377
+ end
378
+
379
+ def save_if_needed
380
+ @persist_next_change && save
381
+ @persist_next_change = false
320
382
  end
321
383
 
322
384
  def load
323
385
  existing_data = File.read(@filename)
324
- @data.update YAML.load(existing_data) unless existing_data.strip.empty?
386
+ @data.update yaml_load(existing_data) unless existing_data.strip.empty?
325
387
  end
326
388
 
327
389
  def save
328
- FileUtils.mkdir_p File.dirname(@filename)
329
- File.open(@filename, 'w', 0600) {|f| f << YAML.dump(@data) }
390
+ mkdir_p File.dirname(@filename)
391
+ File.open(@filename, 'w', 0600) {|f| f << yaml_dump(@data) }
392
+ end
393
+
394
+ def mkdir_p(dir)
395
+ dir.split('/').inject do |parent, name|
396
+ d = File.join(parent, name)
397
+ Dir.mkdir(d) unless File.exist?(d)
398
+ d
399
+ end
400
+ end
401
+
402
+ def yaml_load(string)
403
+ hash = {}
404
+ host = nil
405
+ string.split("\n").each do |line|
406
+ case line
407
+ when /^---\s*$/, /^\s*(?:#|$)/
408
+ # ignore
409
+ when /^(.+):\s*$/
410
+ host = hash[$1] = []
411
+ when /^([- ]) (.+?): (.+)/
412
+ key, value = $2, $3
413
+ host << {} if $1 == '-'
414
+ host.last[key] = value.gsub(/^'|'$/, '')
415
+ else
416
+ raise "unsupported YAML line: #{line}"
417
+ end
418
+ end
419
+ hash
420
+ end
421
+
422
+ def yaml_dump(data)
423
+ yaml = ['---']
424
+ data.each do |host, values|
425
+ yaml << "#{host}:"
426
+ values.each do |hash|
427
+ dash = '-'
428
+ hash.each do |key, value|
429
+ yaml << "#{dash} #{key}: #{value}"
430
+ dash = ' '
431
+ end
432
+ end
433
+ end
434
+ yaml.join("\n")
330
435
  end
331
436
  end
332
437
 
@@ -347,41 +452,36 @@ module Hub
347
452
  def username host
348
453
  return ENV['GITHUB_USER'] unless ENV['GITHUB_USER'].to_s.empty?
349
454
  host = normalize_host host
350
- @data.fetch_user host do
455
+ @data.fetch_value(host, nil, :user) do
351
456
  if block_given? then yield
352
457
  else prompt "#{host} username"
353
458
  end
354
459
  end
355
460
  end
356
461
 
357
- def update_username host, old_username, new_username
358
- entry = @data.entry_for_user(normalize_host(host), old_username)
359
- entry['user'] = new_username
360
- @data.save
361
- end
362
-
363
- def api_token host, user
364
- host = normalize_host host
365
- @data.fetch_value host, user, :api_token do
366
- if block_given? then yield
367
- else prompt "#{host} API token for #{user}"
368
- end
369
- end
370
- end
371
-
372
462
  def password host, user
373
463
  return ENV['GITHUB_PASSWORD'] unless ENV['GITHUB_PASSWORD'].to_s.empty?
374
464
  host = normalize_host host
375
465
  @password_cache["#{user}@#{host}"] ||= prompt_password host, user
376
466
  end
377
467
 
378
- def oauth_token host, user, &block
379
- @data.fetch_value normalize_host(host), user, :oauth_token, &block
468
+ def oauth_token host, user
469
+ host = normalize_host(host)
470
+ @data.fetch_value(host, user, :oauth_token) do
471
+ value_to_persist(yield)
472
+ end
473
+ end
474
+
475
+ def value_to_persist(value = nil)
476
+ @data.persist_next_change!
477
+ value
380
478
  end
381
479
 
382
480
  def prompt what
383
481
  print "#{what}: "
384
482
  $stdin.gets.chomp
483
+ rescue Interrupt
484
+ abort
385
485
  end
386
486
 
387
487
  # special prompt that has hidden input
@@ -395,16 +495,38 @@ module Hub
395
495
  # in testing
396
496
  $stdin.gets.chomp
397
497
  end
498
+ rescue Interrupt
499
+ abort
500
+ end
501
+
502
+ def prompt_auth_code
503
+ print "two-factor authentication code: "
504
+ $stdin.gets.chomp
505
+ rescue Interrupt
506
+ abort
398
507
  end
399
508
 
400
509
  NULL = defined?(File::NULL) ? File::NULL :
401
510
  File.exist?('/dev/null') ? '/dev/null' : 'NUL'
402
511
 
403
512
  def askpass
513
+ noecho $stdin do |input|
514
+ input.gets.chomp
515
+ end
516
+ end
517
+
518
+ def noecho io
519
+ require 'io/console'
520
+ io.noecho { yield io }
521
+ rescue LoadError
522
+ fallback_noecho io
523
+ end
524
+
525
+ def fallback_noecho io
404
526
  tty_state = `stty -g 2>#{NULL}`
405
527
  system 'stty raw -echo -icanon isig' if $?.success?
406
528
  pass = ''
407
- while char = getbyte($stdin) and !(char == 13 or char == 10)
529
+ while char = getbyte(io) and !(char == 13 or char == 10)
408
530
  if char == 127 or char == 8
409
531
  pass[-1,1] = '' unless pass.empty?
410
532
  else