dcuddeback-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 (58) 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.markdown +144 -0
  6. data/Rakefile +94 -0
  7. data/VERSION.yml +4 -0
  8. data/contrib/backup.rb +100 -0
  9. data/dcuddeback-octopi.gemspec +108 -0
  10. data/examples/authenticated.rb +20 -0
  11. data/examples/issues.rb +18 -0
  12. data/examples/overall.rb +50 -0
  13. data/lib/ext/string_ext.rb +5 -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/deploy_key.rb +27 -0
  22. data/lib/octopi/deploy_key_set.rb +18 -0
  23. data/lib/octopi/error.rb +35 -0
  24. data/lib/octopi/file_object.rb +16 -0
  25. data/lib/octopi/gist.rb +28 -0
  26. data/lib/octopi/issue.rb +111 -0
  27. data/lib/octopi/issue_comment.rb +7 -0
  28. data/lib/octopi/issue_set.rb +21 -0
  29. data/lib/octopi/key.rb +25 -0
  30. data/lib/octopi/key_set.rb +14 -0
  31. data/lib/octopi/plan.rb +5 -0
  32. data/lib/octopi/repository.rb +136 -0
  33. data/lib/octopi/repository_set.rb +9 -0
  34. data/lib/octopi/resource.rb +70 -0
  35. data/lib/octopi/self.rb +33 -0
  36. data/lib/octopi/tag.rb +23 -0
  37. data/lib/octopi/user.rb +131 -0
  38. data/lib/octopi.rb +135 -0
  39. data/test/api_test.rb +58 -0
  40. data/test/authenticated_test.rb +39 -0
  41. data/test/base_test.rb +20 -0
  42. data/test/blob_test.rb +23 -0
  43. data/test/branch_test.rb +20 -0
  44. data/test/commit_test.rb +82 -0
  45. data/test/file_object_test.rb +39 -0
  46. data/test/gist_test.rb +16 -0
  47. data/test/issue_comment.rb +19 -0
  48. data/test/issue_set_test.rb +33 -0
  49. data/test/issue_test.rb +120 -0
  50. data/test/key_set_test.rb +29 -0
  51. data/test/key_test.rb +35 -0
  52. data/test/repository_set_test.rb +23 -0
  53. data/test/repository_test.rb +151 -0
  54. data/test/stubs/commits/fcoury/octopi/octopi.rb +818 -0
  55. data/test/tag_test.rb +20 -0
  56. data/test/test_helper.rb +246 -0
  57. data/test/user_test.rb +92 -0
  58. metadata +151 -0
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
+ 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
@@ -0,0 +1,31 @@
1
+ module Octopi
2
+ class Branch < Base
3
+ attr_accessor :name, :sha
4
+ include Resource
5
+ set_resource_name "branch", "branches"
6
+
7
+ resource_path "/repos/show/:id"
8
+
9
+ # Called when we ask for a resource.
10
+ # Arguments are passed in like [<name>, <sha>]
11
+ # TODO: Find out why args are doubly nested
12
+ def initialize(*args)
13
+ args = args.flatten!
14
+ self.name = args.first
15
+ self.sha = args.last
16
+ end
17
+
18
+ def to_s
19
+ name
20
+ end
21
+
22
+ def self.all(options={})
23
+ ensure_hash(options)
24
+ user, repo = gather_details(options)
25
+ self.validate_args(user => :user, repo => :repo)
26
+ BranchSet.new(find_plural([user, repo, 'branches'], :resource)) do |i|
27
+ { :name => i.first, :hash => i.last }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ require File.join(File.dirname(__FILE__), "branch")
2
+ class Octopi::BranchSet < Array
3
+ include Octopi
4
+ attr_accessor :user, :repository
5
+ # Takes a name, returns a branch if it exists
6
+ def find(name)
7
+ branch = detect { |b| b.name == name }
8
+ raise NotFound, Branch if branch.nil?
9
+ branch
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ module Octopi
2
+ class Comment < Base
3
+ attr_accessor :content, :author, :title, :updated, :link, :published, :id, :repository
4
+ include Resource
5
+ set_resource_name "tree"
6
+
7
+ resource_path "/tree/show/:id"
8
+
9
+ def self.find(options={})
10
+ ensure_hash(options)
11
+ user, repo, branch, sha = gather_details(options)
12
+ self.validate_args(sha => :sha, user => :user, repo => :repo)
13
+ super [user, repo, sha]
14
+ end
15
+
16
+ def commit
17
+ Commit.find(:user => repository.owner, :repo => repository, :sha => /commit\/(.*?)#/.match(link)[1])
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,69 @@
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, :message, :parents, :author, :url, :id, :committed_date, :authored_date, :tree, :committer, :added, :removed, :modified
8
+
9
+
10
+ # Finds all commits for the given options:
11
+ #
12
+ # :repo or :repository or :name - A repository object or the name of a repository
13
+ # :user - A user object or the login of a user
14
+ # :branch - A branch object or the name of a branch. Defaults to master.
15
+ #
16
+ # Sample usage:
17
+ #
18
+ # >> find_all(:user => "fcoury", :repo => "octopi")
19
+ # => <Latest 30 commits for master branch>
20
+ #
21
+ # => find_all(:user => "fcoury", :repo => "octopi", :branch => "lazy") # branch is set to lazy.
22
+ # => <Latest 30 commits for lazy branch>
23
+ #
24
+ def self.find_all(options={})
25
+ ensure_hash(options)
26
+ user, repo, branch = gather_details(options)
27
+ commits = if options[:path]
28
+ super user, repo.name, branch, options[:path]
29
+ else
30
+ super user, repo.name, branch
31
+ end
32
+ # Repository is not passed in from the data, set it manually.
33
+ commits.each { |c| c.repository = repo }
34
+ commits
35
+ end
36
+
37
+ # Finds all commits for the given options:
38
+ #
39
+ # :repo or :repository or :name - A repository object or the name of a repository
40
+ # :user - A user object or the login of a user
41
+ # :branch - A branch object or the name of a branch. Defaults to master.
42
+ # :sha - The commit ID
43
+ #
44
+ # Sample usage:
45
+ #
46
+ # >> find(:user => "fcoury", :repo => "octopi", :sha => "f6609209c3ac0badd004512d318bfaa508ea10ae")
47
+ # => <Commit f6609209c3ac0badd004512d318bfaa508ea10ae for branch master>
48
+ #
49
+ # >> find(:user => "fcoury", :repo => "octopi", :branch => "lazy", :sha => "f6609209c3ac0badd004512d318bfaa508ea10ae") # branch is set to lazy.
50
+ # => <Commit f6609209c3ac0badd004512d318bfaa508ea10ae for branch lazy>
51
+ #
52
+ def self.find(options={})
53
+ ensure_hash(options)
54
+ user, repo, branch, sha = gather_details(options)
55
+ super [user, repo, sha]
56
+ end
57
+
58
+ def repo_identifier
59
+ url_parts = url.split('/')
60
+ if @repository
61
+ parts = [@repository.owner, @repository.name, url_parts[6]]
62
+ else
63
+ parts = [url_parts[3], url_parts[4], url_parts[6]]
64
+ end
65
+
66
+ parts.join('/')
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,27 @@
1
+ module Octopi
2
+ class DeployKey < Base
3
+ include Resource
4
+ set_resource_name "public_key"
5
+
6
+ attr_accessor :title, :id, :key
7
+
8
+ create_path "/repos/key/:id/add"
9
+ delete_path "/repos/key/:id/remove"
10
+ find_path "/repos/keys/:id"
11
+
12
+ def self.all(options={})
13
+ ensure_hash(options)
14
+ user, repo = gather_details(options)
15
+ self.validate_args(repo => :repo)
16
+ DeployKeySet.new(repo, find_plural([repo], :find))
17
+ end
18
+
19
+ def self.create(options={})
20
+ ensure_hash(options)
21
+ repo = options.delete(:repo)
22
+ options[:id] = repo.name
23
+ resp = Api.api.post(path_for(:create), options.merge(:cache => false))
24
+ DeployKeySet.new(repo, resp[resource_name(:plural)].map {|k| self.new(k)})
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ require File.join(File.dirname(__FILE__), "key")
2
+ class Octopi::DeployKeySet < Array
3
+ include Octopi
4
+ attr_accessor :repository
5
+
6
+ def initialize(repo, keys)
7
+ self.repository = repo
8
+ super keys
9
+ end
10
+
11
+ def find(title)
12
+ detect {|key| key.title == title}
13
+ end
14
+
15
+ def add(options={})
16
+ DeployKey.create(options.merge(:repo => repository))
17
+ end
18
+ end
@@ -0,0 +1,35 @@
1
+ module Octopi
2
+
3
+ class FormatError < StandardError
4
+ def initialize(f)
5
+ super("Got unexpected format (got #{f.first} for #{f.last})")
6
+ end
7
+ end
8
+
9
+ class AuthenticationRequired < StandardError
10
+ end
11
+
12
+ class APIError < StandardError
13
+ end
14
+
15
+ class InvalidLogin < StandardError
16
+ end
17
+
18
+ class RetryableAPIError < RuntimeError
19
+ attr_reader :code
20
+ def initialize(code=nil)
21
+ @code = code.nil? ? '???' : code
22
+ @message = "GitHub returned status #{@code}. Retrying request."
23
+ super @message
24
+ end
25
+ end
26
+
27
+ class ArgumentMustBeHash < Exception; end
28
+
29
+
30
+ class NotFound < Exception
31
+ def initialize(klass)
32
+ super "The #{klass.to_s.split("::").last} you were looking for could not be found, or is private."
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,16 @@
1
+ module Octopi
2
+ class FileObject < Base
3
+ attr_accessor :name, :sha, :mode, :type
4
+
5
+ include Resource
6
+ set_resource_name "tree"
7
+ resource_path "/tree/show/:id"
8
+
9
+ def self.find(options={})
10
+ ensure_hash(options)
11
+ user, repo, branch, sha = gather_details(options)
12
+ self.validate_args(sha => :sha, user => :user, repo => :repo)
13
+ super [user, repo, sha]
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ module Octopi
2
+ # Gist API is... lacking at the moment.
3
+ # This class serves only as a reminder to implement it later
4
+ class Gist < Base
5
+ include HTTParty
6
+ attr_accessor :description, :repo, :public, :created_at
7
+
8
+ include Resource
9
+ set_resource_name "tree"
10
+ resource_path ":id"
11
+
12
+ def self.base_uri
13
+ "http://gist.github.com/api/v1/yaml"
14
+ end
15
+
16
+ def self.find(id)
17
+ result = get("#{base_uri}/#{id}")
18
+ # This returns an array of Gists, rather than a single record.
19
+ new(result["gists"].first)
20
+ end
21
+
22
+ # def files
23
+ # gists_folder = File.join(ENV['HOME'], ".octopi", "gists")
24
+ # File.mkdir_p(gists_folder)
25
+ # `git clone git://`
26
+ # end
27
+ end
28
+ end