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