tpitale-octopi 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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