devver-octopi 0.2.8
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/.yardoc +0 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +20 -0
- data/README.rdoc +144 -0
- data/Rakefile +100 -0
- data/VERSION.yml +5 -0
- data/contrib/backup.rb +100 -0
- data/examples/authenticated.rb +20 -0
- data/examples/issues.rb +18 -0
- data/examples/overall.rb +50 -0
- data/lib/ext/hash_ext.rb +5 -0
- data/lib/ext/string_ext.rb +5 -0
- data/lib/octopi.rb +136 -0
- data/lib/octopi/api.rb +213 -0
- data/lib/octopi/base.rb +115 -0
- data/lib/octopi/blob.rb +25 -0
- data/lib/octopi/branch.rb +31 -0
- data/lib/octopi/branch_set.rb +11 -0
- data/lib/octopi/comment.rb +20 -0
- data/lib/octopi/commit.rb +69 -0
- data/lib/octopi/error.rb +35 -0
- data/lib/octopi/file_object.rb +16 -0
- data/lib/octopi/gist.rb +28 -0
- data/lib/octopi/issue.rb +111 -0
- data/lib/octopi/issue_comment.rb +7 -0
- data/lib/octopi/issue_set.rb +21 -0
- data/lib/octopi/key.rb +25 -0
- data/lib/octopi/key_set.rb +14 -0
- data/lib/octopi/plan.rb +5 -0
- data/lib/octopi/repository.rb +132 -0
- data/lib/octopi/repository_set.rb +9 -0
- data/lib/octopi/resource.rb +70 -0
- data/lib/octopi/self.rb +33 -0
- data/lib/octopi/tag.rb +23 -0
- data/lib/octopi/user.rb +123 -0
- data/octopi.gemspec +99 -0
- data/test/api_test.rb +58 -0
- data/test/authenticated_test.rb +39 -0
- data/test/blob_test.rb +23 -0
- data/test/branch_test.rb +20 -0
- data/test/commit_test.rb +82 -0
- data/test/file_object_test.rb +39 -0
- data/test/gist_test.rb +16 -0
- data/test/issue_comment.rb +19 -0
- data/test/issue_set_test.rb +33 -0
- data/test/issue_test.rb +120 -0
- data/test/key_set_test.rb +29 -0
- data/test/key_test.rb +35 -0
- data/test/repository_set_test.rb +23 -0
- data/test/repository_test.rb +151 -0
- data/test/stubs/commits/fcoury/octopi/octopi.rb +818 -0
- data/test/tag_test.rb +20 -0
- data/test/test_helper.rb +246 -0
- data/test/user_test.rb +92 -0
- metadata +153 -0
data/lib/ext/hash_ext.rb
ADDED
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
|
data/lib/octopi/base.rb
ADDED
@@ -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
|