hub 1.8.4 → 1.9.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/README.md +3 -18
- data/Rakefile +6 -2
- data/lib/hub.rb +2 -0
- data/lib/hub/commands.rb +51 -155
- data/lib/hub/context.rb +45 -185
- data/lib/hub/github_api.rb +359 -0
- data/lib/hub/json.rb +36 -0
- data/lib/hub/ssh_config.rb +91 -0
- data/lib/hub/standalone.rb +30 -29
- data/lib/hub/version.rb +1 -1
- data/man/hub.1 +3 -47
- data/man/hub.1.html +4 -25
- data/man/hub.1.ronn +3 -21
- data/test/helper.rb +14 -1
- data/test/hub_test.rb +100 -430
- data/test/standalone_test.rb +8 -3
- metadata +10 -8
@@ -0,0 +1,359 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'yaml'
|
3
|
+
require 'forwardable'
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
module Hub
|
7
|
+
# Client for the GitHub v3 API.
|
8
|
+
#
|
9
|
+
# First time around, user gets prompted for username/password in the shell.
|
10
|
+
# Then this information is exchanged for an OAuth token which is saved in a file.
|
11
|
+
#
|
12
|
+
# Examples
|
13
|
+
#
|
14
|
+
# @api_client ||= begin
|
15
|
+
# config_file = ENV['HUB_CONFIG'] || '~/.config/hub'
|
16
|
+
# file_store = GitHubAPI::FileStore.new File.expand_path(config_file)
|
17
|
+
# file_config = GitHubAPI::Configuration.new file_store
|
18
|
+
# GitHubAPI.new file_config, :app_url => 'http://defunkt.io/hub/'
|
19
|
+
# end
|
20
|
+
class GitHubAPI
|
21
|
+
attr_reader :config, :oauth_app_url
|
22
|
+
|
23
|
+
# Public: Create a new API client instance
|
24
|
+
#
|
25
|
+
# Options:
|
26
|
+
# - config: an object that implements:
|
27
|
+
# - username(host)
|
28
|
+
# - api_token(host, user)
|
29
|
+
# - password(host, user)
|
30
|
+
# - oauth_token(host, user)
|
31
|
+
def initialize config, options
|
32
|
+
@config = config
|
33
|
+
@oauth_app_url = options.fetch(:app_url)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Fake exception type for net/http exception handling.
|
37
|
+
# Necessary because net/http may or may not be loaded at the time.
|
38
|
+
module Exceptions
|
39
|
+
def self.===(exception)
|
40
|
+
exception.class.ancestors.map {|a| a.to_s }.include? 'Net::HTTPExceptions'
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def api_host host
|
45
|
+
host = host.downcase
|
46
|
+
'github.com' == host ? 'api.github.com' : host
|
47
|
+
end
|
48
|
+
|
49
|
+
# Public: Determine whether a specific repo already exists.
|
50
|
+
def repo_exists? project
|
51
|
+
res = get "https://%s/repos/%s/%s" %
|
52
|
+
[api_host(project.host), project.owner, project.name]
|
53
|
+
res.success?
|
54
|
+
end
|
55
|
+
|
56
|
+
# Public: Fork the specified repo.
|
57
|
+
def fork_repo project
|
58
|
+
res = post "https://%s/repos/%s/%s/forks" %
|
59
|
+
[api_host(project.host), project.owner, project.name]
|
60
|
+
res.error! unless res.success?
|
61
|
+
end
|
62
|
+
|
63
|
+
# Public: Create a new project.
|
64
|
+
def create_repo project, options = {}
|
65
|
+
is_org = project.owner != config.username(api_host(project.host))
|
66
|
+
params = { :name => project.name, :private => !!options[:private] }
|
67
|
+
params[:description] = options[:description] if options[:description]
|
68
|
+
params[:homepage] = options[:homepage] if options[:homepage]
|
69
|
+
|
70
|
+
if is_org
|
71
|
+
res = post "https://%s/orgs/%s/repos" % [api_host(project.host), project.owner], params
|
72
|
+
else
|
73
|
+
res = post "https://%s/user/repos" % api_host(project.host), params
|
74
|
+
end
|
75
|
+
res.error! unless res.success?
|
76
|
+
end
|
77
|
+
|
78
|
+
# Public: Fetch info about a pull request.
|
79
|
+
def pullrequest_info project, pull_id
|
80
|
+
res = get "https://%s/repos/%s/%s/pulls/%d" %
|
81
|
+
[api_host(project.host), project.owner, project.name, pull_id]
|
82
|
+
res.error! unless res.success?
|
83
|
+
res.data
|
84
|
+
end
|
85
|
+
|
86
|
+
# Returns parsed data from the new pull request.
|
87
|
+
def create_pullrequest options
|
88
|
+
project = options.fetch(:project)
|
89
|
+
params = {
|
90
|
+
:base => options.fetch(:base),
|
91
|
+
:head => options.fetch(:head)
|
92
|
+
}
|
93
|
+
|
94
|
+
if options[:issue]
|
95
|
+
params[:issue] = options[:issue]
|
96
|
+
else
|
97
|
+
params[:title] = options[:title] if options[:title]
|
98
|
+
params[:body] = options[:body] if options[:body]
|
99
|
+
end
|
100
|
+
|
101
|
+
res = post "https://%s/repos/%s/%s/pulls" %
|
102
|
+
[api_host(project.host), project.owner, project.name], params
|
103
|
+
|
104
|
+
res.error! unless res.success?
|
105
|
+
res.data
|
106
|
+
end
|
107
|
+
|
108
|
+
# Methods for performing HTTP requests
|
109
|
+
#
|
110
|
+
# Requires access to a `config` object that implements `proxy_uri(with_ssl)`
|
111
|
+
module HttpMethods
|
112
|
+
# Decorator for Net::HTTPResponse
|
113
|
+
module ResponseMethods
|
114
|
+
def status() code.to_i end
|
115
|
+
def data?() content_type =~ /\bjson\b/ end
|
116
|
+
def data() @data ||= JSON.parse(body) end
|
117
|
+
def error_message?() data? and data['errors'] || data['message'] end
|
118
|
+
def error_message() error_sentences || data['message'] end
|
119
|
+
def success?() Net::HTTPSuccess === self end
|
120
|
+
def error_sentences
|
121
|
+
data['errors'].map do |err|
|
122
|
+
case err['code']
|
123
|
+
when 'custom' then err['message']
|
124
|
+
when 'missing_field' then "field '%s' is missing" % err['field']
|
125
|
+
end
|
126
|
+
end.compact if data['errors']
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def get url, &block
|
131
|
+
perform_request url, :Get, &block
|
132
|
+
end
|
133
|
+
|
134
|
+
def post url, params = nil
|
135
|
+
perform_request url, :Post do |req|
|
136
|
+
if params
|
137
|
+
req.body = JSON.dump params
|
138
|
+
req['Content-Type'] = 'application/json'
|
139
|
+
end
|
140
|
+
yield req if block_given?
|
141
|
+
req['Content-Length'] = req.body ? req.body.length : 0
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def post_form url, params
|
146
|
+
post(url) {|req| req.set_form_data params }
|
147
|
+
end
|
148
|
+
|
149
|
+
def perform_request url, type
|
150
|
+
url = URI.parse url unless url.respond_to? :hostname
|
151
|
+
|
152
|
+
require 'net/https'
|
153
|
+
req = Net::HTTP.const_get(type).new(url.request_uri)
|
154
|
+
http = create_connection(url)
|
155
|
+
|
156
|
+
apply_authentication(req, url)
|
157
|
+
yield req if block_given?
|
158
|
+
res = http.start { http.request(req) }
|
159
|
+
res.extend ResponseMethods
|
160
|
+
res
|
161
|
+
rescue SocketError => err
|
162
|
+
raise Context::FatalError, "error with #{type.to_s.upcase} #{url} (#{err.message})"
|
163
|
+
end
|
164
|
+
|
165
|
+
def apply_authentication req, url
|
166
|
+
user = url.user || config.username(url.host)
|
167
|
+
pass = config.password(url.host, user)
|
168
|
+
req.basic_auth user, pass
|
169
|
+
end
|
170
|
+
|
171
|
+
def create_connection url
|
172
|
+
use_ssl = 'https' == url.scheme
|
173
|
+
|
174
|
+
proxy_args = []
|
175
|
+
if proxy = config.proxy_uri(use_ssl)
|
176
|
+
proxy_args << proxy.host << proxy.port
|
177
|
+
if proxy.userinfo
|
178
|
+
require 'cgi'
|
179
|
+
# proxy user + password
|
180
|
+
proxy_args.concat proxy.userinfo.split(':', 2).map {|a| CGI.unescape a }
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
http = Net::HTTP.new(url.host, url.port, *proxy_args)
|
185
|
+
|
186
|
+
if http.use_ssl = use_ssl
|
187
|
+
# FIXME: enable SSL peer verification!
|
188
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
189
|
+
end
|
190
|
+
return http
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
module OAuth
|
195
|
+
def apply_authentication req, url
|
196
|
+
if req.path.index('/authorizations') == 0
|
197
|
+
super
|
198
|
+
else
|
199
|
+
user = url.user || config.username(url.host)
|
200
|
+
token = config.oauth_token(url.host, user) {
|
201
|
+
obtain_oauth_token url.host, user
|
202
|
+
}
|
203
|
+
req['Authorization'] = "token #{token}"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def obtain_oauth_token host, user
|
208
|
+
# first try to fetch existing authorization
|
209
|
+
res = get "https://#{user}@#{host}/authorizations"
|
210
|
+
res.error! unless res.success?
|
211
|
+
|
212
|
+
if found = res.data.find {|auth| auth['app']['url'] == oauth_app_url }
|
213
|
+
found['token']
|
214
|
+
else
|
215
|
+
# create a new authorization
|
216
|
+
res = post "https://#{user}@#{host}/authorizations",
|
217
|
+
:scopes => %w[repo], :note => 'hub', :note_url => oauth_app_url
|
218
|
+
res.error! unless res.success?
|
219
|
+
res.data['token']
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
include HttpMethods
|
225
|
+
include OAuth
|
226
|
+
|
227
|
+
# Filesystem store suitable for Configuration
|
228
|
+
class FileStore
|
229
|
+
extend Forwardable
|
230
|
+
def_delegator :@data, :[], :get
|
231
|
+
def_delegator :@data, :[]=, :set
|
232
|
+
|
233
|
+
def initialize filename
|
234
|
+
@filename = filename
|
235
|
+
@data = Hash.new {|d, host| d[host] = [] }
|
236
|
+
load if File.exist? filename
|
237
|
+
end
|
238
|
+
|
239
|
+
def fetch_user host
|
240
|
+
unless entry = get(host).first
|
241
|
+
user = yield
|
242
|
+
# FIXME: more elegant handling of empty strings
|
243
|
+
return nil if user.nil? or user.empty?
|
244
|
+
entry = entry_for_user(host, user)
|
245
|
+
end
|
246
|
+
entry['user']
|
247
|
+
end
|
248
|
+
|
249
|
+
def fetch_value host, user, key
|
250
|
+
entry = entry_for_user host, user
|
251
|
+
entry[key.to_s] || begin
|
252
|
+
value = yield
|
253
|
+
if value and !value.empty?
|
254
|
+
entry[key.to_s] = value
|
255
|
+
save
|
256
|
+
value
|
257
|
+
else
|
258
|
+
raise "no value"
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
def entry_for_user host, username
|
264
|
+
entries = get(host)
|
265
|
+
entries.find {|e| e['user'] == username } or
|
266
|
+
(entries << {'user' => username}).last
|
267
|
+
end
|
268
|
+
|
269
|
+
def load
|
270
|
+
@data.update YAML.load(File.read(@filename))
|
271
|
+
end
|
272
|
+
|
273
|
+
def save
|
274
|
+
FileUtils.mkdir_p File.dirname(@filename)
|
275
|
+
File.open(@filename, 'w') {|f| f << YAML.dump(@data) }
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# Provides authentication info per GitHub host such as username, password,
|
280
|
+
# and API/OAuth tokens.
|
281
|
+
class Configuration
|
282
|
+
def initialize store
|
283
|
+
@data = store
|
284
|
+
# passwords are cached in memory instead of persistent store
|
285
|
+
@password_cache = {}
|
286
|
+
end
|
287
|
+
|
288
|
+
def normalize_host host
|
289
|
+
host = host.downcase
|
290
|
+
'api.github.com' == host ? 'github.com' : host
|
291
|
+
end
|
292
|
+
|
293
|
+
def username host
|
294
|
+
host = normalize_host host
|
295
|
+
@data.fetch_user host do
|
296
|
+
if block_given? then yield
|
297
|
+
else prompt "#{host} username"
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def api_token host, user
|
303
|
+
host = normalize_host host
|
304
|
+
@data.fetch_value host, user, :api_token do
|
305
|
+
if block_given? then yield
|
306
|
+
else prompt "#{host} API token for #{user}"
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
def password host, user
|
312
|
+
host = normalize_host host
|
313
|
+
@password_cache["#{user}@#{host}"] ||= prompt_password host, user
|
314
|
+
end
|
315
|
+
|
316
|
+
def oauth_token host, user, &block
|
317
|
+
@data.fetch_value normalize_host(host), user, :oauth_token, &block
|
318
|
+
end
|
319
|
+
|
320
|
+
def prompt what
|
321
|
+
print "#{what}: "
|
322
|
+
$stdin.gets.chomp
|
323
|
+
end
|
324
|
+
|
325
|
+
# special prompt that has hidden input
|
326
|
+
def prompt_password host, user
|
327
|
+
print "#{host} password for #{user} (never stored): "
|
328
|
+
password = askpass
|
329
|
+
puts ''
|
330
|
+
password
|
331
|
+
end
|
332
|
+
|
333
|
+
# FIXME: probably not cross-platform
|
334
|
+
def askpass
|
335
|
+
tty_state = `stty -g`
|
336
|
+
system 'stty raw -echo -icanon isig' if $?.success?
|
337
|
+
pass = ''
|
338
|
+
while char = $stdin.getbyte and not (char == 13 or char == 10)
|
339
|
+
if char == 127 or char == 8
|
340
|
+
pass[-1,1] = '' unless pass.empty?
|
341
|
+
else
|
342
|
+
pass << char.chr
|
343
|
+
end
|
344
|
+
end
|
345
|
+
pass
|
346
|
+
ensure
|
347
|
+
system "stty #{tty_state}" unless tty_state.empty?
|
348
|
+
end
|
349
|
+
|
350
|
+
def proxy_uri(with_ssl)
|
351
|
+
env_name = "HTTP#{with_ssl ? 'S' : ''}_PROXY"
|
352
|
+
if proxy = ENV[env_name] || ENV[env_name.downcase]
|
353
|
+
proxy = "http://#{proxy}" unless proxy.include? '://'
|
354
|
+
URI.parse proxy
|
355
|
+
end
|
356
|
+
end
|
357
|
+
end
|
358
|
+
end
|
359
|
+
end
|
data/lib/hub/json.rb
CHANGED
@@ -94,4 +94,40 @@ class Hub::JSON
|
|
94
94
|
error unless s.pos > pos
|
95
95
|
end
|
96
96
|
end
|
97
|
+
|
98
|
+
module Generator
|
99
|
+
def generate(obj)
|
100
|
+
raise ArgumentError unless obj.is_a? Array or obj.is_a? Hash
|
101
|
+
generate_type(obj)
|
102
|
+
end
|
103
|
+
alias dump generate
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def generate_type(obj)
|
108
|
+
type = obj.is_a?(Numeric) ? :Numeric : obj.class.name
|
109
|
+
begin send(:"generate_#{type}", obj)
|
110
|
+
rescue NoMethodError; raise ArgumentError, "can't serialize #{type}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def generate_String(str) str.inspect end
|
115
|
+
alias generate_Numeric generate_String
|
116
|
+
alias generate_TrueClass generate_String
|
117
|
+
alias generate_FalseClass generate_String
|
118
|
+
|
119
|
+
def generate_Symbol(sym) generate_String(sym.to_s) end
|
120
|
+
|
121
|
+
def generate_NilClass(*) 'null' end
|
122
|
+
|
123
|
+
def generate_Array(ary) '[%s]' % ary.map {|o| generate_type(o) }.join(', ') end
|
124
|
+
|
125
|
+
def generate_Hash(hash)
|
126
|
+
'{%s}' % hash.map { |key, value|
|
127
|
+
"#{generate_String(key.to_s)}: #{generate_type(value)}"
|
128
|
+
}.join(', ')
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
extend Generator
|
97
133
|
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module Hub
|
2
|
+
# Reads ssh configuration files and records each setting under its host
|
3
|
+
# pattern so it can be looked up by hostname.
|
4
|
+
class SshConfig
|
5
|
+
CONFIG_FILES = %w(~/.ssh/config /etc/ssh_config /etc/ssh/ssh_config)
|
6
|
+
|
7
|
+
def initialize files = nil
|
8
|
+
@settings = Hash.new {|h,k| h[k] = {} }
|
9
|
+
Array(files || CONFIG_FILES).each do |path|
|
10
|
+
file = File.expand_path path
|
11
|
+
parse_file file if File.exist? file
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Public: Get a setting as it would apply to a specific hostname.
|
16
|
+
#
|
17
|
+
# Yields if not found.
|
18
|
+
def get_value hostname, key
|
19
|
+
key = key.to_s.downcase
|
20
|
+
@settings.each do |pattern, settings|
|
21
|
+
if pattern.match? hostname and found = settings[key]
|
22
|
+
return found
|
23
|
+
end
|
24
|
+
end
|
25
|
+
yield
|
26
|
+
end
|
27
|
+
|
28
|
+
class HostPattern
|
29
|
+
def initialize pattern
|
30
|
+
@pattern = pattern.to_s.downcase
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s() @pattern end
|
34
|
+
def ==(other) other.to_s == self.to_s end
|
35
|
+
|
36
|
+
def matcher
|
37
|
+
@matcher ||=
|
38
|
+
if '*' == @pattern
|
39
|
+
Proc.new { true }
|
40
|
+
elsif @pattern !~ /[?*]/
|
41
|
+
lambda { |hostname| hostname.to_s.downcase == @pattern }
|
42
|
+
else
|
43
|
+
re = self.class.pattern_to_regexp @pattern
|
44
|
+
lambda { |hostname| re =~ hostname }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def match? hostname
|
49
|
+
matcher.call hostname
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.pattern_to_regexp pattern
|
53
|
+
escaped = Regexp.escape(pattern)
|
54
|
+
escaped.gsub!('\*', '.*')
|
55
|
+
escaped.gsub!('\?', '.')
|
56
|
+
/^#{escaped}$/i
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def parse_file file
|
61
|
+
host_patterns = [HostPattern.new('*')]
|
62
|
+
|
63
|
+
IO.foreach(file) do |line|
|
64
|
+
case line
|
65
|
+
when /^\s*(#|$)/ then next
|
66
|
+
when /^\s*(\S+)\s*=/
|
67
|
+
key, value = $1, $'
|
68
|
+
else
|
69
|
+
key, value = line.strip.split(/\s+/, 2)
|
70
|
+
end
|
71
|
+
|
72
|
+
next if value.nil?
|
73
|
+
key.downcase!
|
74
|
+
value = $1 if value =~ /^"(.*)"$/
|
75
|
+
value.chomp!
|
76
|
+
|
77
|
+
if 'host' == key
|
78
|
+
host_patterns = value.split(/\s+/).map {|p| HostPattern.new p }
|
79
|
+
else
|
80
|
+
record_setting key, value, host_patterns
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def record_setting key, value, patterns
|
86
|
+
patterns.each do |pattern|
|
87
|
+
@settings[pattern][key] ||= value
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|