devver-octopi 0.2.8

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 +4 -0
  2. data/.yardoc +0 -0
  3. data/CHANGELOG.md +9 -0
  4. data/LICENSE +20 -0
  5. data/README.rdoc +144 -0
  6. data/Rakefile +100 -0
  7. data/VERSION.yml +5 -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/hash_ext.rb +5 -0
  13. data/lib/ext/string_ext.rb +5 -0
  14. data/lib/octopi.rb +136 -0
  15. data/lib/octopi/api.rb +213 -0
  16. data/lib/octopi/base.rb +115 -0
  17. data/lib/octopi/blob.rb +25 -0
  18. data/lib/octopi/branch.rb +31 -0
  19. data/lib/octopi/branch_set.rb +11 -0
  20. data/lib/octopi/comment.rb +20 -0
  21. data/lib/octopi/commit.rb +69 -0
  22. data/lib/octopi/error.rb +35 -0
  23. data/lib/octopi/file_object.rb +16 -0
  24. data/lib/octopi/gist.rb +28 -0
  25. data/lib/octopi/issue.rb +111 -0
  26. data/lib/octopi/issue_comment.rb +7 -0
  27. data/lib/octopi/issue_set.rb +21 -0
  28. data/lib/octopi/key.rb +25 -0
  29. data/lib/octopi/key_set.rb +14 -0
  30. data/lib/octopi/plan.rb +5 -0
  31. data/lib/octopi/repository.rb +132 -0
  32. data/lib/octopi/repository_set.rb +9 -0
  33. data/lib/octopi/resource.rb +70 -0
  34. data/lib/octopi/self.rb +33 -0
  35. data/lib/octopi/tag.rb +23 -0
  36. data/lib/octopi/user.rb +123 -0
  37. data/octopi.gemspec +99 -0
  38. data/test/api_test.rb +58 -0
  39. data/test/authenticated_test.rb +39 -0
  40. data/test/blob_test.rb +23 -0
  41. data/test/branch_test.rb +20 -0
  42. data/test/commit_test.rb +82 -0
  43. data/test/file_object_test.rb +39 -0
  44. data/test/gist_test.rb +16 -0
  45. data/test/issue_comment.rb +19 -0
  46. data/test/issue_set_test.rb +33 -0
  47. data/test/issue_test.rb +120 -0
  48. data/test/key_set_test.rb +29 -0
  49. data/test/key_test.rb +35 -0
  50. data/test/repository_set_test.rb +23 -0
  51. data/test/repository_test.rb +151 -0
  52. data/test/stubs/commits/fcoury/octopi/octopi.rb +818 -0
  53. data/test/tag_test.rb +20 -0
  54. data/test/test_helper.rb +246 -0
  55. data/test/user_test.rb +92 -0
  56. metadata +153 -0
@@ -0,0 +1,5 @@
1
+ class Hash
2
+ def method_missing(method, *args)
3
+ self[method] || self[method.to_s]
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class String
2
+ def camel_case
3
+ self.gsub(/(^|_)(.)/) { $2.upcase }
4
+ end
5
+ end
data/lib/octopi.rb ADDED
@@ -0,0 +1,136 @@
1
+ require 'rubygems'
2
+
3
+ require 'httparty'
4
+ require 'mechanize'
5
+ require 'nokogiri'
6
+ require 'api_cache'
7
+
8
+ require 'yaml'
9
+ require 'pp'
10
+
11
+ # Core extension stuff
12
+ Dir[File.join(File.dirname(__FILE__), "ext/*.rb")].each { |f| require f }
13
+
14
+ # Octopi stuff
15
+ # By sorting them we ensure that api and base are loaded first on all sane operating systems
16
+ Dir[File.join(File.dirname(__FILE__), "octopi/*.rb")].sort.each { |f| require f }
17
+
18
+ # Include this into your app so you can access the child classes easier.
19
+ # This is the root of all things Octopi.
20
+ module Octopi
21
+
22
+ # The authenticated methods are all very similar.
23
+ # TODO: Find a way to merge them into something... better.
24
+
25
+ def authenticated(options={}, &block)
26
+ begin
27
+ config = config = File.open(options[:config]) { |yf| YAML::load(yf) } if options[:config]
28
+ config = read_gitconfig
29
+ options[:login] = config["github"]["user"]
30
+ options[:token] = config["github"]["token"]
31
+
32
+ authenticated_with(options) do
33
+ yield
34
+ end
35
+ ensure
36
+ # Reset authenticated so if we were to do an anonymous call it would Just Work(tm)
37
+ Api.authenticated = false
38
+ Api.api = AnonymousApi.instance
39
+ end
40
+ end
41
+
42
+ def authenticated_with(options, &block)
43
+ begin
44
+
45
+ Api.api.trace_level = options[:trace] if options[:trace]
46
+
47
+ if options[:token].nil? && !options[:password].nil?
48
+ options[:token] = grab_token(options[:login], options[:password])
49
+ end
50
+ begin
51
+ User.find(options[:login])
52
+ # If the user cannot see themselves then they are not logged in, tell them so
53
+ rescue Octopi::NotFound
54
+ raise Octopi::InvalidLogin
55
+ end
56
+
57
+ trace("=> Trace on: #{options[:trace]}")
58
+
59
+ Api.api = AuthApi.instance
60
+ Api.api.login = options[:login]
61
+ Api.api.token = options[:token]
62
+
63
+ yield
64
+ ensure
65
+ # Reset authenticated so if we were to do an anonymous call it would Just Work(tm)
66
+ Api.authenticated = false
67
+ Api.api = AnonymousApi.instance
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def grab_token(username, password)
74
+ a = WWW::Mechanize.new { |agent|
75
+ # Fake out the agent
76
+ agent.user_agent_alias = 'Mac Safari'
77
+ }
78
+
79
+ # Login with the provided
80
+ a.get('http://github.com/login') do |page|
81
+ user_page = page.form_with(:action => '/session') do |login|
82
+ login.login = username
83
+ login.password = password
84
+ end.submit
85
+
86
+
87
+ if Api.api.trace_level
88
+ File.open("got.html", "w+") do |f|
89
+ f.write user_page.body
90
+ end
91
+ `open got.html`
92
+ end
93
+
94
+ body = Nokogiri::HTML(user_page.body)
95
+ error = body.xpath("//div[@class='error_box']").text
96
+ raise error if error != ""
97
+
98
+ # Should be clear to go if there is no errors.
99
+ link = user_page.link_with(:text => "account")
100
+ @account_page = a.click(link)
101
+ if Api.api.trace_level
102
+ File.open("account.html", "w+") do |f|
103
+ f.write @account_page.body
104
+ end
105
+ `open account.html`
106
+ end
107
+
108
+ return Nokogiri::HTML(@account_page.body).xpath("//p").xpath("strong")[1].text
109
+ end
110
+ end
111
+
112
+
113
+ def read_gitconfig
114
+ config = {}
115
+ group = nil
116
+ File.foreach("#{ENV['HOME']}/.gitconfig") do |line|
117
+ line.strip!
118
+ if line[0] != ?# && line =~ /\S/
119
+ if line =~ /^\[(.*)\]$/
120
+ group = $1
121
+ config[group] ||= {}
122
+ else
123
+ key, value = line.split("=").map { |v| v.strip }
124
+ config[group][key] = value
125
+ end
126
+ end
127
+ end
128
+ config
129
+ end
130
+
131
+ def trace(text)
132
+ if Api.api.trace_level
133
+ puts "text"
134
+ end
135
+ end
136
+ end
data/lib/octopi/api.rb ADDED
@@ -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
+ raise "no attr_accessor set for #{key} on #{self.class}" if !respond_to?("#{key}=")
30
+ self.send("#{key}=", value)
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