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.

@@ -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