tpitale-octopi 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. data/.gitignore +5 -0
  2. data/.yardoc +0 -0
  3. data/CHANGELOG.md +9 -0
  4. data/LICENSE +20 -0
  5. data/README.markdown +137 -0
  6. data/Rakefile +91 -0
  7. data/VERSION.yml +4 -0
  8. data/contrib/backup.rb +100 -0
  9. data/examples/authenticated.rb +20 -0
  10. data/examples/issues.rb +18 -0
  11. data/examples/overall.rb +50 -0
  12. data/lib/ext/string_ext.rb +5 -0
  13. data/lib/octopi.rb +92 -0
  14. data/lib/octopi/api.rb +213 -0
  15. data/lib/octopi/base.rb +115 -0
  16. data/lib/octopi/blob.rb +25 -0
  17. data/lib/octopi/branch.rb +31 -0
  18. data/lib/octopi/branch_set.rb +11 -0
  19. data/lib/octopi/comment.rb +20 -0
  20. data/lib/octopi/commit.rb +69 -0
  21. data/lib/octopi/error.rb +35 -0
  22. data/lib/octopi/file_object.rb +16 -0
  23. data/lib/octopi/gist.rb +28 -0
  24. data/lib/octopi/issue.rb +111 -0
  25. data/lib/octopi/issue_comment.rb +7 -0
  26. data/lib/octopi/issue_set.rb +21 -0
  27. data/lib/octopi/key.rb +25 -0
  28. data/lib/octopi/key_set.rb +14 -0
  29. data/lib/octopi/plan.rb +5 -0
  30. data/lib/octopi/repository.rb +130 -0
  31. data/lib/octopi/repository_set.rb +9 -0
  32. data/lib/octopi/resource.rb +70 -0
  33. data/lib/octopi/self.rb +33 -0
  34. data/lib/octopi/tag.rb +23 -0
  35. data/lib/octopi/user.rb +131 -0
  36. data/test/api_test.rb +58 -0
  37. data/test/authenticated_test.rb +38 -0
  38. data/test/base_test.rb +20 -0
  39. data/test/blob_test.rb +23 -0
  40. data/test/branch_test.rb +20 -0
  41. data/test/commit_test.rb +82 -0
  42. data/test/file_object_test.rb +39 -0
  43. data/test/gist_test.rb +16 -0
  44. data/test/issue_comment.rb +19 -0
  45. data/test/issue_set_test.rb +33 -0
  46. data/test/issue_test.rb +120 -0
  47. data/test/key_set_test.rb +29 -0
  48. data/test/key_test.rb +35 -0
  49. data/test/repository_set_test.rb +23 -0
  50. data/test/repository_test.rb +151 -0
  51. data/test/stubs/commits/fcoury/octopi/octopi.rb +818 -0
  52. data/test/tag_test.rb +20 -0
  53. data/test/test_helper.rb +246 -0
  54. data/test/user_test.rb +92 -0
  55. data/tpitale-octopi.gemspec +99 -0
  56. metadata +142 -0
@@ -0,0 +1,5 @@
1
+ class String
2
+ def camel_case
3
+ self.gsub(/(^|_)(.)/) { $2.upcase }
4
+ end
5
+ end
@@ -0,0 +1,92 @@
1
+ require 'rubygems'
2
+
3
+ require 'httparty'
4
+ require 'api_cache'
5
+
6
+ require 'yaml'
7
+ require 'pp'
8
+
9
+ # Core extension stuff
10
+ Dir[File.join(File.dirname(__FILE__), "ext/*.rb")].each { |f| require f }
11
+
12
+ # Octopi stuff
13
+ # By sorting them we ensure that api and base are loaded first on all sane operating systems
14
+ Dir[File.join(File.dirname(__FILE__), "octopi/*.rb")].sort.each { |f| require f }
15
+
16
+ # Include this into your app so you can access the child classes easier.
17
+ # This is the root of all things Octopi.
18
+ module Octopi
19
+
20
+ # The authenticated methods are all very similar.
21
+ # TODO: Find a way to merge them into something... better.
22
+
23
+ def authenticated(options={}, &block)
24
+ begin
25
+ config = config = File.open(options[:config]) { |yf| YAML::load(yf) } if options[:config]
26
+ config = read_gitconfig
27
+ options[:login] = config["github"]["user"]
28
+ options[:token] = config["github"]["token"]
29
+
30
+ authenticated_with(options) do
31
+ yield
32
+ end
33
+ ensure
34
+ # Reset authenticated so if we were to do an anonymous call it would Just Work(tm)
35
+ Api.authenticated = false
36
+ Api.api = AnonymousApi.instance
37
+ end
38
+ end
39
+
40
+ def authenticated_with(options, &block)
41
+ begin
42
+
43
+ Api.api.trace_level = options[:trace] if options[:trace]
44
+
45
+ raise "This version of octopi requires a token be set" if options[:token].nil?
46
+
47
+ begin
48
+ User.find(options[:login])
49
+ # If the user cannot see themselves then they are not logged in, tell them so
50
+ rescue Octopi::NotFound
51
+ raise Octopi::InvalidLogin
52
+ end
53
+
54
+ trace("=> Trace on: #{options[:trace]}")
55
+
56
+ Api.api = AuthApi.instance
57
+ Api.api.login = options[:login]
58
+ Api.api.token = options[:token]
59
+
60
+ yield
61
+ ensure
62
+ # Reset authenticated so if we were to do an anonymous call it would Just Work(tm)
63
+ Api.authenticated = false
64
+ Api.api = AnonymousApi.instance
65
+ end
66
+ end
67
+
68
+ private
69
+ def read_gitconfig
70
+ config = {}
71
+ group = nil
72
+ File.foreach("#{ENV['HOME']}/.gitconfig") do |line|
73
+ line.strip!
74
+ if line[0] != ?# && line =~ /\S/
75
+ if line =~ /^\[(.*)\]$/
76
+ group = $1
77
+ config[group] ||= {}
78
+ else
79
+ key, value = line.split("=").map { |v| v.strip }
80
+ config[group][key] = value
81
+ end
82
+ end
83
+ end
84
+ config
85
+ end
86
+
87
+ def trace(text)
88
+ if Api.api.trace_level
89
+ puts "text"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,213 @@
1
+ require 'singleton'
2
+ require File.join(File.dirname(__FILE__), "self")
3
+ module Octopi
4
+ # Dummy class, so AnonymousApi and AuthApi have somewhere to inherit from
5
+ class Api
6
+ include Self
7
+ attr_accessor :format, :login, :token, :trace_level, :read_only
8
+ end
9
+
10
+ # Used for accessing the Github API anonymously
11
+ class AnonymousApi < Api
12
+ include HTTParty
13
+ include Singleton
14
+ base_uri "http://github.com/api/v2"
15
+
16
+ def read_only?
17
+ true
18
+ end
19
+
20
+ def auth_parameters
21
+ { }
22
+ end
23
+ end
24
+
25
+ class AuthApi < Api
26
+ include HTTParty
27
+ include Singleton
28
+ base_uri "https://github.com/api/v2"
29
+
30
+ def read_only?
31
+ false
32
+ end
33
+
34
+ def auth_parameters
35
+ { :login => Api.me.login, :token => Api.me.token }
36
+ end
37
+ end
38
+
39
+ # This is the real API class.
40
+ #
41
+ # API requests are limited to 60 per minute.
42
+ #
43
+ # Sets up basic methods for accessing the API.
44
+ class Api
45
+ @@api = Octopi::AnonymousApi.instance
46
+ @@authenticated = false
47
+
48
+ include Singleton
49
+ CONTENT_TYPE = {
50
+ 'yaml' => ['application/x-yaml', 'text/yaml', 'text/x-yaml', 'application/yaml'],
51
+ 'json' => 'application/json',
52
+ 'xml' => 'application/xml',
53
+ # Unexpectedly, Github returns resources such as blobs as text/html!
54
+ # Thus, plain == text/html.
55
+ 'plain' => ['text/plain', 'text/html']
56
+ }
57
+ RETRYABLE_STATUS = [403]
58
+ MAX_RETRIES = 10
59
+ # Would be nice if cattr_accessor was available, oh well.
60
+
61
+ # We use this to check if we use the auth or anonymous api
62
+ def self.authenticated
63
+ @@authenticated
64
+ end
65
+
66
+ # We set this to true when the user has auth'd.
67
+ def self.authenticated=(value)
68
+ @@authenticated = value
69
+ end
70
+
71
+ # The API we're using
72
+ def self.api
73
+ @@api
74
+ end
75
+
76
+ class << self
77
+ alias_method :me, :api
78
+ end
79
+
80
+ # set the API we're using
81
+ def self.api=(value)
82
+ @@api = value
83
+ end
84
+
85
+
86
+ def user
87
+ user_data = get("/user/show/#{login}")
88
+ raise "Unexpected response for user command" unless user_data and user_data['user']
89
+ User.new(user_data['user'])
90
+ end
91
+
92
+ def save(resource_path, data)
93
+ traslate resource_path, data
94
+ #still can't figure out on what format values are expected
95
+ post("#{resource_path}", { :query => data })
96
+ end
97
+
98
+
99
+ def find(path, result_key, resource_id, klass=nil, cache=true)
100
+ result = get(path, { :id => resource_id, :cache => cache }, klass)
101
+ result
102
+ end
103
+
104
+
105
+ def find_all(path, result_key, query, klass=nil, cache=true)
106
+ { :query => query, :id => query, :cache => cache }
107
+ result = get(path, { :query => query, :id => query, :cache => cache }, klass)
108
+ result[result_key]
109
+ end
110
+
111
+ def get_raw(path, params, klass=nil)
112
+ get(path, params, klass, 'plain')
113
+ end
114
+
115
+ def get(path, params = {}, klass=nil, format = :yaml)
116
+ @@retries = 0
117
+ begin
118
+ submit(path, params, klass, format) do |path, params, format, query|
119
+ self.class.get "/#{format}#{path}", { :format => format, :query => query }
120
+ end
121
+ rescue RetryableAPIError => e
122
+ if @@retries < MAX_RETRIES
123
+ $stderr.puts e.message
124
+ if e.code != 403
125
+ @@retries += 1
126
+ sleep 6
127
+ retry
128
+ else
129
+ raise APIError, "Github returned status #{e.code}, you may not have access to this resource."
130
+ end
131
+ else
132
+ raise APIError, "GitHub returned status #{e.code}, despite" +
133
+ " repeating the request #{MAX_RETRIES} times. Giving up."
134
+ end
135
+ end
136
+ end
137
+
138
+ def post(path, params = {}, klass=nil, format = :yaml)
139
+ @@retries = 0
140
+ begin
141
+ trace "POST", "/#{format}#{path}", params
142
+ submit(path, params, klass, format) do |path, params, format, query|
143
+ resp = self.class.post "/#{format}#{path}", { :body => params, :format => format, :query => query }
144
+ resp
145
+ end
146
+ rescue RetryableAPIError => e
147
+ if @@retries < MAX_RETRIES
148
+ $stderr.puts e.message
149
+ @@retries += 1
150
+ sleep 6
151
+ retry
152
+ else
153
+ raise APIError, "GitHub returned status #{e.code}, despite" +
154
+ " repeating the request #{MAX_RETRIES} times. Giving up."
155
+ end
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def method_missing(method, *args)
162
+ api.send(method, *args)
163
+ end
164
+
165
+ def submit(path, params = {}, klass=nil, format = :yaml, &block)
166
+ # Ergh. Ugly way to do this. Find a better one!
167
+ cache = params.delete(:cache)
168
+ cache = true if cache.nil?
169
+ params.each_pair do |k,v|
170
+ if path =~ /:#{k.to_s}/
171
+ params.delete(k)
172
+ path = path.gsub(":#{k.to_s}", v)
173
+ end
174
+ end
175
+ begin
176
+ key = "#{Api.api.class.to_s}:#{path}"
177
+ resp = if cache
178
+ APICache.get(key, :cache => 61) do
179
+ yield(path, params, format, auth_parameters)
180
+ end
181
+ else
182
+ yield(path, params, format, auth_parameters)
183
+ end
184
+ rescue Net::HTTPBadResponse
185
+ raise RetryableAPIError
186
+ end
187
+
188
+ raise RetryableAPIError, resp.code.to_i if RETRYABLE_STATUS.include? resp.code.to_i
189
+ # puts resp.code.inspect
190
+ raise NotFound, klass || self.class if resp.code.to_i == 404
191
+ raise APIError,
192
+ "GitHub returned status #{resp.code}" unless resp.code.to_i == 200
193
+ # FIXME: This fails for showing raw Git data because that call returns
194
+ # text/html as the content type. This issue has been reported.
195
+
196
+ # It happens, in tests.
197
+ return resp if resp.headers.empty?
198
+ ctype = resp.headers['content-type'].first.split(";").first
199
+ raise FormatError, [ctype, format] unless CONTENT_TYPE[format.to_s].include?(ctype)
200
+ if format == 'yaml' && resp['error']
201
+ raise APIError, resp['error']
202
+ end
203
+ resp
204
+ end
205
+
206
+ def trace(oper, url, params)
207
+ return unless trace_level
208
+ par_str = " params: " + params.map { |p| "#{p[0]}=#{p[1]}" }.join(", ") if params && !params.empty?
209
+ puts "#{oper}: #{url}#{par_str}"
210
+ end
211
+
212
+ end
213
+ end
@@ -0,0 +1,115 @@
1
+ module Octopi
2
+ class Base
3
+ VALID = {
4
+ :repo => {
5
+ # FIXME: API currently chokes on repository names containing periods,
6
+ # but presumably this will be fixed.
7
+ :pat => /^[A-Za-z0-9_\.-]+$/,
8
+ :msg => "%s is an invalid repository name"},
9
+ :user => {
10
+ :pat => /^[A-Za-z0-9_\.-]+$/,
11
+ :msg => "%s is an invalid username"},
12
+ :sha => {
13
+ :pat => /^[a-f0-9]{40}$/,
14
+ :msg => "%s is an invalid SHA hash"},
15
+ :state => {
16
+ # FIXME: Any way to access Issue::STATES from here?
17
+ :pat => /^(open|closed)$/,
18
+ :msg => "%s is an invalid state; should be 'open' or 'closed'."
19
+ }
20
+ }
21
+
22
+ attr_accessor :api
23
+
24
+ def initialize(attributes={})
25
+ # Useful for finding out what attr_accessor needs for classes
26
+ # puts caller.first.inspect
27
+ # puts "#{self.class.inspect} #{attributes.keys.map { |s| s.to_sym }.inspect}"
28
+ attributes.each do |key, value|
29
+ method = "#{key}="
30
+ self.send(method, value) if respond_to? method
31
+ end
32
+ end
33
+
34
+ def error=(error)
35
+ if /\w+ not found/.match(error)
36
+ raise NotFound, self.class
37
+ end
38
+ end
39
+
40
+ def property(p, v)
41
+ path = "#{self.class.path_for(:resource)}/#{p}"
42
+ Api.api.find(path, self.class.resource_name(:singular), v)
43
+ end
44
+
45
+ def save
46
+ hash = {}
47
+ @keys.each { |k| hash[k] = send(k) }
48
+ Api.api.save(self.path_for(:resource), hash)
49
+ end
50
+
51
+ private
52
+
53
+ def self.gather_name(options)
54
+ options[:repository] || options[:repo] || options[:name]
55
+ end
56
+
57
+ def self.gather_details(options)
58
+ repo = self.gather_name(options)
59
+ repo = Repository.find(:user => options[:user], :name => repo) if !repo.is_a?(Repository)
60
+ user = repo.owner.to_s
61
+ user ||= options[:user].to_s
62
+ branch = options[:branch] || "master"
63
+ self.validate_args(user => :user, repo.name => :repo)
64
+ [user, repo, branch, options[:sha]].compact
65
+ end
66
+
67
+ def self.extract_user_repository(*args)
68
+ options = args.last.is_a?(Hash) ? args.pop : {}
69
+ if options.empty?
70
+ if args.length > 1
71
+ repo, user = *args
72
+ else
73
+ repo = args.pop
74
+ end
75
+ else
76
+ options[:repo] = options[:repository] if options[:repository]
77
+ repo = args.pop || options[:repo]
78
+ user = options[:user]
79
+ end
80
+
81
+ user = repo.owner if repo.is_a? Repository
82
+
83
+ if repo.is_a?(String) && !user
84
+ raise "Need user argument when repository is identified by name"
85
+ end
86
+ ret = extract_names(user, repo)
87
+ ret << options
88
+ ret
89
+ end
90
+
91
+ def self.extract_names(*args)
92
+ args.map do |v|
93
+ v = v.name if v.is_a? Repository
94
+ v = v.login if v.is_a? User
95
+ v
96
+ end
97
+ end
98
+
99
+ def self.ensure_hash(spec)
100
+ raise ArgumentMustBeHash, "find takes a hash of options as a solitary argument" if !spec.is_a?(Hash)
101
+ end
102
+
103
+ def self.validate_args(spec)
104
+ m = caller[0].match(/\/([a-z0-9_]+)\.rb:\d+:in `([a-z_0-9]+)'/)
105
+ meth = m ? "#{m[1].camel_case}.#{m[2]}" : 'method'
106
+ raise ArgumentError, "Invalid spec" unless
107
+ spec.values.all? { |s| VALID.key? s }
108
+ errors = spec.reject{|arg, spec| arg.nil?}.
109
+ reject{|arg, spec| arg.to_s.match(VALID[spec][:pat])}.
110
+ map {|arg, spec| "Invalid argument '%s' for %s (%s)" %
111
+ [arg, meth, VALID[spec][:msg] % arg]}
112
+ raise ArgumentError, "\n" + errors.join("\n") unless errors.empty?
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,25 @@
1
+ require File.join(File.dirname(__FILE__), "resource")
2
+ module Octopi
3
+ class Blob < Base
4
+ attr_accessor :text, :data, :name, :sha, :size, :mode, :mime_type
5
+ include Resource
6
+ set_resource_name "blob"
7
+
8
+ resource_path "/blob/show/:id"
9
+
10
+ def self.find(options={})
11
+ ensure_hash(options)
12
+ user, repo = gather_details(options)
13
+ sha = options[:sha]
14
+ path = options[:path]
15
+
16
+ self.validate_args(sha => :sha, user => :user)
17
+
18
+ if path
19
+ super [user, repo, sha, path]
20
+ else
21
+ Api.api.get_raw(path_for(:resource), {:id => [user, repo, sha].join('/')})
22
+ end
23
+ end
24
+ end
25
+ end