ddollar-octopi 0.0.13
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 +3 -0
- data/LICENSE +20 -0
- data/README.rdoc +169 -0
- data/Rakefile +83 -0
- data/VERSION.yml +4 -0
- data/contrib/backup.rb +100 -0
- data/examples/authenticated.rb +20 -0
- data/examples/github.yml.example +14 -0
- data/examples/issues.rb +18 -0
- data/examples/overall.rb +50 -0
- data/lib/octopi.rb +236 -0
- data/lib/octopi/base.rb +111 -0
- data/lib/octopi/blob.rb +21 -0
- data/lib/octopi/branch.rb +18 -0
- data/lib/octopi/commit.rb +65 -0
- data/lib/octopi/error.rb +23 -0
- data/lib/octopi/file_object.rb +15 -0
- data/lib/octopi/issue.rb +102 -0
- data/lib/octopi/key.rb +18 -0
- data/lib/octopi/repository.rb +111 -0
- data/lib/octopi/resource.rb +75 -0
- data/lib/octopi/tag.rb +17 -0
- data/lib/octopi/user.rb +99 -0
- data/octopi.gemspec +66 -0
- data/test/octopi_test.rb +46 -0
- data/test/test_helper.rb +10 -0
- metadata +83 -0
data/lib/octopi.rb
ADDED
@@ -0,0 +1,236 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'httparty'
|
3
|
+
require 'yaml'
|
4
|
+
require 'pp'
|
5
|
+
|
6
|
+
module Octopi
|
7
|
+
def authenticated(*args, &block)
|
8
|
+
opts = args.last.is_a?(Hash) ? args.last : {}
|
9
|
+
config = read_gitconfig
|
10
|
+
login = config["github"]["user"]
|
11
|
+
token = config["github"]["token"]
|
12
|
+
|
13
|
+
api = AuthApi.new(login, token)
|
14
|
+
api.trace_level = opts[:trace]
|
15
|
+
|
16
|
+
puts "=> Trace on: #{api.trace_level}" if api.trace_level
|
17
|
+
|
18
|
+
yield api
|
19
|
+
end
|
20
|
+
|
21
|
+
def authenticated_with(*args, &block)
|
22
|
+
opts = args.last.is_a?(Hash) ? args.last : {}
|
23
|
+
if opts[:config]
|
24
|
+
config = File.open(opts[:config]) { |yf| YAML::load(yf) }
|
25
|
+
raise "Missing config #{opts[:config]}" unless config
|
26
|
+
|
27
|
+
login = config["login"]
|
28
|
+
token = config["token"]
|
29
|
+
trace = config["trace"]
|
30
|
+
else
|
31
|
+
login, token = *args
|
32
|
+
end
|
33
|
+
|
34
|
+
puts "=> Trace on: #{trace}" if trace
|
35
|
+
|
36
|
+
api = AuthApi.new(login, token)
|
37
|
+
api.trace_level = trace if trace
|
38
|
+
yield api
|
39
|
+
end
|
40
|
+
|
41
|
+
def read_gitconfig
|
42
|
+
config = {}
|
43
|
+
group = nil
|
44
|
+
File.foreach("#{ENV['HOME']}/.gitconfig") do |line|
|
45
|
+
line.strip!
|
46
|
+
if line[0] != ?# and line =~ /\S/
|
47
|
+
if line =~ /^\[(.*)\]$/
|
48
|
+
group = $1
|
49
|
+
else
|
50
|
+
key, value = line.split("=")
|
51
|
+
value ||= ''
|
52
|
+
(config[group]||={})[key.strip] = value.strip
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
config
|
57
|
+
end
|
58
|
+
|
59
|
+
class Api
|
60
|
+
CONTENT_TYPE = {
|
61
|
+
'yaml' => 'application/x-yaml',
|
62
|
+
'json' => 'application/json',
|
63
|
+
'xml' => 'application/sml'
|
64
|
+
}
|
65
|
+
RETRYABLE_STATUS = [403]
|
66
|
+
MAX_RETRIES = 10
|
67
|
+
|
68
|
+
attr_accessor :format, :login, :token, :trace_level, :read_only
|
69
|
+
|
70
|
+
def initialize(login = nil, token = nil, format = "yaml")
|
71
|
+
@format = format
|
72
|
+
@read_only = true
|
73
|
+
|
74
|
+
if login
|
75
|
+
@login = login
|
76
|
+
@token = token
|
77
|
+
@read_only = false
|
78
|
+
self.class.default_params :login => login, :token => token
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def read_only?
|
83
|
+
read_only
|
84
|
+
end
|
85
|
+
|
86
|
+
{:keys => 'public_keys', :emails => 'emails'}.each_pair do |action, key|
|
87
|
+
define_method("#{action}") do
|
88
|
+
get("/user/#{action}")[key]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def user
|
93
|
+
user_data = get("/user/show/#{login}")
|
94
|
+
raise "Unexpected response for user command" unless user_data and user_data['user']
|
95
|
+
User.new(self, user_data['user'])
|
96
|
+
end
|
97
|
+
|
98
|
+
def open_issue(user, repo, params)
|
99
|
+
Issue.open(user, repo, params, self)
|
100
|
+
end
|
101
|
+
|
102
|
+
def repository(name)
|
103
|
+
repo = Repository.find(user, name, self)
|
104
|
+
repo.api = self
|
105
|
+
repo
|
106
|
+
end
|
107
|
+
alias_method :repo, :repository
|
108
|
+
|
109
|
+
def commits(repo,opts={})
|
110
|
+
branch = opts[:branch] || "master"
|
111
|
+
commits = Commit.find_all(repo, branch, self)
|
112
|
+
end
|
113
|
+
|
114
|
+
def save(resource_path, data)
|
115
|
+
traslate resource_path, data
|
116
|
+
#still can't figure out on what format values are expected
|
117
|
+
post("#{resource_path}", { :query => data })
|
118
|
+
end
|
119
|
+
|
120
|
+
def find(path, result_key, resource_id)
|
121
|
+
get(path, { :id => resource_id })
|
122
|
+
end
|
123
|
+
|
124
|
+
def find_all(path, result_key, query)
|
125
|
+
get(path, { :query => query, :id => query })[result_key]
|
126
|
+
end
|
127
|
+
|
128
|
+
def get_raw(path, params)
|
129
|
+
get(path, params, 'plain')
|
130
|
+
end
|
131
|
+
|
132
|
+
def get(path, params = {}, format = "yaml")
|
133
|
+
@@retries = 0
|
134
|
+
begin
|
135
|
+
trace "GET [#{format}]", "/#{format}#{path}", params
|
136
|
+
submit(path, params, format) do |path, params, format|
|
137
|
+
self.class.get "/#{format}#{path}"
|
138
|
+
end
|
139
|
+
rescue RetryableAPIError => e
|
140
|
+
if @@retries < MAX_RETRIES
|
141
|
+
$stderr.puts e.message
|
142
|
+
@@retries += 1
|
143
|
+
sleep 6
|
144
|
+
retry
|
145
|
+
else
|
146
|
+
raise APIError, "GitHub returned status #{e.code}, despite" +
|
147
|
+
" repeating the request #{MAX_RETRIES} times. Giving up."
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def post(path, params = {}, format = "yaml")
|
153
|
+
@@retries = 0
|
154
|
+
begin
|
155
|
+
trace "POST", "/#{format}#{path}", params
|
156
|
+
submit(path, params, format) do |path, params, format|
|
157
|
+
resp = self.class.post "/#{format}#{path}", :query => params
|
158
|
+
resp
|
159
|
+
end
|
160
|
+
rescue RetryableAPIError => e
|
161
|
+
if @@retries < MAX_RETRIES
|
162
|
+
$stderr.puts e.message
|
163
|
+
@@retries += 1
|
164
|
+
sleep 6
|
165
|
+
retry
|
166
|
+
else
|
167
|
+
raise APIError, "GitHub returned status #{e.code}, despite" +
|
168
|
+
" repeating the request #{MAX_RETRIES} times. Giving up."
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
def submit(path, params = {}, format = "yaml", &block)
|
175
|
+
params.each_pair do |k,v|
|
176
|
+
if path =~ /:#{k.to_s}/
|
177
|
+
params.delete(k)
|
178
|
+
path = path.gsub(":#{k.to_s}", v)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
query = login ? { :login => login, :token => token } : {}
|
182
|
+
query.merge!(params)
|
183
|
+
|
184
|
+
begin
|
185
|
+
resp = yield(path, query.merge(params), format)
|
186
|
+
rescue Net::HTTPBadResponse
|
187
|
+
raise RetryableAPIError
|
188
|
+
end
|
189
|
+
|
190
|
+
if @trace_level
|
191
|
+
case @trace_level
|
192
|
+
when "curl"
|
193
|
+
query_trace = []
|
194
|
+
query.each_pair { |k,v| query_trace << "-F '#{k}=#{v}'" }
|
195
|
+
puts "===== [curl version]"
|
196
|
+
puts "curl #{query_trace.join(" ")} #{self.class.base_uri}/#{format}#{path}"
|
197
|
+
puts "===================="
|
198
|
+
end
|
199
|
+
end
|
200
|
+
raise RetryableAPIError, resp.code.to_i if RETRYABLE_STATUS.include? resp.code.to_i
|
201
|
+
raise APIError,
|
202
|
+
"GitHub returned status #{resp.code}" unless resp.code.to_i == 200
|
203
|
+
# FIXME: This fails for showing raw Git data because that call returns
|
204
|
+
# text/html as the content type. This issue has been reported.
|
205
|
+
ctype = resp.headers['content-type'].first
|
206
|
+
raise FormatError, [ctype, format] unless
|
207
|
+
ctype.match(/^#{CONTENT_TYPE[format]};/)
|
208
|
+
if format == 'yaml' && resp['error']
|
209
|
+
raise APIError, resp['error'].first['error']
|
210
|
+
end
|
211
|
+
resp
|
212
|
+
end
|
213
|
+
|
214
|
+
def trace(oper, url, params)
|
215
|
+
return unless trace_level
|
216
|
+
par_str = " params: " + params.map { |p| "#{p[0]}=#{p[1]}" }.join(", ") if params and !params.empty?
|
217
|
+
puts "#{oper}: #{url}#{par_str}"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
class AuthApi < Api
|
222
|
+
include HTTParty
|
223
|
+
base_uri "https://github.com/api/v2"
|
224
|
+
end
|
225
|
+
|
226
|
+
class AnonymousApi < Api
|
227
|
+
include HTTParty
|
228
|
+
base_uri "http://github.com/api/v2"
|
229
|
+
end
|
230
|
+
|
231
|
+
ANONYMOUS_API = AnonymousApi.new
|
232
|
+
|
233
|
+
%w{error base resource user tag repository issue file_object blob key commit branch}.
|
234
|
+
each{|f| require "#{File.dirname(__FILE__)}/octopi/#{f}"}
|
235
|
+
|
236
|
+
end
|
data/lib/octopi/base.rb
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
class String
|
2
|
+
def camel_case
|
3
|
+
self.gsub(/(^|_)(.)/) { $2.upcase }
|
4
|
+
end
|
5
|
+
end
|
6
|
+
module Octopi
|
7
|
+
class Base
|
8
|
+
VALID = {
|
9
|
+
:repo => {
|
10
|
+
# FIXME: API currently chokes on repository names containing periods,
|
11
|
+
# but presumably this will be fixed.
|
12
|
+
:pat => /^[A-Za-z0-9_\.-]+$/,
|
13
|
+
:msg => "%s is an invalid repository name"},
|
14
|
+
:user => {
|
15
|
+
:pat => /^[A-Za-z0-9_\.-]+$/,
|
16
|
+
:msg => "%s is an invalid username"},
|
17
|
+
:file => {
|
18
|
+
:pat => /^[^ \/]+$/,
|
19
|
+
:msg => "%s is an invalid filename"},
|
20
|
+
:sha => {
|
21
|
+
:pat => /^[a-f0-9]{40}$/,
|
22
|
+
:msg => "%s is an invalid SHA hash"},
|
23
|
+
:state => {
|
24
|
+
# FIXME: Any way to access Issue::STATES from here?
|
25
|
+
:pat => /^(open|closed)$/,
|
26
|
+
:msg => "%s is an invalid state; should be 'open' or 'closed'."
|
27
|
+
}
|
28
|
+
}
|
29
|
+
|
30
|
+
attr_accessor :api
|
31
|
+
|
32
|
+
def initialize(api, hash)
|
33
|
+
@api = api
|
34
|
+
@keys = []
|
35
|
+
|
36
|
+
raise "Missing data for #{@resource}" unless hash
|
37
|
+
|
38
|
+
hash.each_pair do |k,v|
|
39
|
+
@keys << k
|
40
|
+
next if k =~ /\./
|
41
|
+
instance_variable_set("@#{k}", v)
|
42
|
+
|
43
|
+
method = (TrueClass === v || FalseClass === v) ? "#{k}?" : k
|
44
|
+
|
45
|
+
self.class.send :define_method, "#{method}=" do |v|
|
46
|
+
instance_variable_set("@#{k}", v)
|
47
|
+
end
|
48
|
+
|
49
|
+
self.class.send :define_method, method do
|
50
|
+
instance_variable_get("@#{k}")
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def property(p, v)
|
56
|
+
path = "#{self.class.path_for(:resource)}/#{p}"
|
57
|
+
@api.find(path, self.class.resource_name(:singular), v)
|
58
|
+
end
|
59
|
+
|
60
|
+
def save
|
61
|
+
hash = {}
|
62
|
+
@keys.each { |k| hash[k] = send(k) }
|
63
|
+
@api.save(self.path_for(:resource), hash)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
def self.extract_user_repository(*args)
|
68
|
+
opts = args.last.is_a?(Hash) ? args.pop : {}
|
69
|
+
if opts.empty?
|
70
|
+
if args.length > 1
|
71
|
+
repo, user = *args
|
72
|
+
else
|
73
|
+
repo = args.pop
|
74
|
+
end
|
75
|
+
else
|
76
|
+
opts[:repo] = opts[:repository] if opts[:repository]
|
77
|
+
repo = args.pop || opts[:repo]
|
78
|
+
user = opts[:user]
|
79
|
+
end
|
80
|
+
|
81
|
+
user = repo.owner if repo.is_a? Repository
|
82
|
+
|
83
|
+
if repo.is_a?(String) and !user
|
84
|
+
raise "Need user argument when repository is identified by name"
|
85
|
+
end
|
86
|
+
ret = extract_names(user, repo)
|
87
|
+
ret << opts
|
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.validate_args(spec)
|
100
|
+
m = caller[0].match(/\/([a-z0-9_]+)\.rb:\d+:in `([a-z_0-9]+)'/)
|
101
|
+
meth = m ? "#{m[1].camel_case}.#{m[2]}" : 'method'
|
102
|
+
raise ArgumentError, "Invalid spec" unless
|
103
|
+
spec.values.all? { |s| VALID.key? s }
|
104
|
+
errors = spec.reject{|arg, spec| arg.nil?}.
|
105
|
+
reject{|arg, spec| arg.to_s.match(VALID[spec][:pat])}.
|
106
|
+
map {|arg, spec| "Invalid argument '%s' for %s (%s)" %
|
107
|
+
[arg, meth, VALID[spec][:msg] % arg]}
|
108
|
+
raise ArgumentError, "\n" + errors.join("\n") unless errors.empty?
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/lib/octopi/blob.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
module Octopi
|
2
|
+
class Blob < Base
|
3
|
+
include Resource
|
4
|
+
set_resource_name "blob"
|
5
|
+
|
6
|
+
resource_path "/blob/show/:id"
|
7
|
+
|
8
|
+
def self.find(user, repo, sha, path=nil)
|
9
|
+
user = user.login if user.is_a? User
|
10
|
+
repo = repo.name if repo.is_a? Repository
|
11
|
+
self.class.validate_args(sha => :sha, user => :user, path => :file)
|
12
|
+
if path
|
13
|
+
super [user,repo,sha,path]
|
14
|
+
else
|
15
|
+
blob = ANONYMOUS_API.get_raw(path_for(:resource),
|
16
|
+
{:id => [user,repo,sha].join('/')})
|
17
|
+
new(ANONYMOUS_API, {:text => blob})
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Octopi
|
2
|
+
class Branch < Base
|
3
|
+
include Resource
|
4
|
+
set_resource_name "branch", "branches"
|
5
|
+
|
6
|
+
resource_path "/repos/show/:id"
|
7
|
+
|
8
|
+
def self.find(user, repo, api=ANONYMOUS_API)
|
9
|
+
user = user.login if user.is_a? User
|
10
|
+
repo = repo.name if repo.is_a? Repository
|
11
|
+
self.validate_args(user => :user, repo => :repo)
|
12
|
+
api = ANONYMOUS_API if repo.is_a?(Repository) && !repo.private
|
13
|
+
find_plural([user,repo,'branches'], :resource, api){
|
14
|
+
|i| {:name => i.first, :hash => i.last }
|
15
|
+
}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module Octopi
|
2
|
+
class Commit < Base
|
3
|
+
include Resource
|
4
|
+
find_path "/commits/list/:query"
|
5
|
+
resource_path "/commits/show/:id"
|
6
|
+
|
7
|
+
attr_accessor :repository
|
8
|
+
|
9
|
+
# Finds all commits for a given Repository's branch
|
10
|
+
#
|
11
|
+
# You can provide the user and repo parameters as
|
12
|
+
# String or as User and Repository objects. When repo
|
13
|
+
# is provided as a Repository object, user is superfluous.
|
14
|
+
#
|
15
|
+
# If no branch is given, "master" is assumed.
|
16
|
+
#
|
17
|
+
# Sample usage:
|
18
|
+
#
|
19
|
+
# find_all(repo, :branch => "develop") # repo must be an object
|
20
|
+
# find_all("octopi", :user => "fcoury") # user must be provided
|
21
|
+
# find_all(:user => "fcoury", :repo => "octopi") # branch defaults to master
|
22
|
+
#
|
23
|
+
def self.find_all(*args)
|
24
|
+
api = args.last.is_a?(Api) ? args.pop : ANONYMOUS_API
|
25
|
+
repo = args.first
|
26
|
+
user ||= repo.owner if repo.is_a? Repository
|
27
|
+
user, repo_name, opts = extract_user_repository(*args)
|
28
|
+
self.validate_args(user => :user, repo_name => :repo)
|
29
|
+
branch = opts[:branch] || "master"
|
30
|
+
api = ANONYMOUS_API if repo.is_a?(Repository) && !repo.private
|
31
|
+
commits = super user, repo_name, branch, api
|
32
|
+
commits.each { |c| c.repository = repo } if repo.is_a? Repository
|
33
|
+
commits
|
34
|
+
end
|
35
|
+
|
36
|
+
# TODO: Make find use hashes like find_all
|
37
|
+
def self.find(*args)
|
38
|
+
if args.last.is_a?(Commit)
|
39
|
+
commit = args.pop
|
40
|
+
super "#{commit.repo_identifier}"
|
41
|
+
else
|
42
|
+
user, name, sha = *args
|
43
|
+
user = user.login if user.is_a? User
|
44
|
+
name = repo.name if name.is_a? Repository
|
45
|
+
self.validate_args(user => :user, name => :repo, sha => :sha)
|
46
|
+
super [user, name, sha]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def details
|
51
|
+
self.class.find(self)
|
52
|
+
end
|
53
|
+
|
54
|
+
def repo_identifier
|
55
|
+
url_parts = url.split('/')
|
56
|
+
if @repository
|
57
|
+
parts = [@repository.owner, @repository.name, url_parts[6]]
|
58
|
+
else
|
59
|
+
parts = [url_parts[3], url_parts[4], url_parts[6]]
|
60
|
+
end
|
61
|
+
|
62
|
+
parts.join('/')
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|