foca-integrity 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +139 -0
- data/Rakefile +87 -0
- data/app.rb +247 -0
- data/bin/integrity +60 -0
- data/config/config.sample.ru +30 -0
- data/config/config.sample.yml +8 -0
- data/config/thin.sample.yml +14 -0
- data/integrity.gemspec +65 -0
- data/lib/integrity.rb +47 -0
- data/lib/integrity/build.rb +56 -0
- data/lib/integrity/builder.rb +42 -0
- data/lib/integrity/core_ext/object.rb +6 -0
- data/lib/integrity/core_ext/string.rb +5 -0
- data/lib/integrity/core_ext/time.rb +13 -0
- data/lib/integrity/notifier.rb +49 -0
- data/lib/integrity/notifier/base.rb +56 -0
- data/lib/integrity/project.rb +83 -0
- data/lib/integrity/scm.rb +22 -0
- data/lib/integrity/scm/git.rb +72 -0
- data/lib/integrity/scm/git/uri.rb +57 -0
- data/lib/integrity/version.rb +3 -0
- data/public/buttons.css +82 -0
- data/public/reset.css +7 -0
- data/public/spinner.gif +0 -0
- data/spec/form_field_matchers.rb +91 -0
- data/spec/spec_helper.rb +131 -0
- data/vendor/sinatra-hacks/lib/hacks.rb +49 -0
- data/views/build.haml +2 -0
- data/views/build_info.haml +22 -0
- data/views/home.haml +14 -0
- data/views/integrity.sass +361 -0
- data/views/layout.haml +25 -0
- data/views/new.haml +53 -0
- data/views/not_found.haml +31 -0
- data/views/notifier.haml +7 -0
- data/views/project.haml +32 -0
- data/views/unauthorized.haml +38 -0
- metadata +197 -0
data/README.markdown
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
Integrity
|
2
|
+
=========
|
3
|
+
|
4
|
+
Integrity is your friendly automated Continuous Integration server.
|
5
|
+
|
6
|
+
It's fully usable from within its web interface (backed by [Sinatra][]),
|
7
|
+
allowing you to add a project, set preferences for it (where's the code
|
8
|
+
repository, is it completely private or public, etc), and run the build command
|
9
|
+
from there.
|
10
|
+
|
11
|
+
It has been designed with ruby projects in mind, but any project that can be
|
12
|
+
tested in an unix-y fashion (with a command line tool that returns 0 on success
|
13
|
+
and non-zero on failure) works with it.
|
14
|
+
|
15
|
+
Integrity works out of the box with [git][] projects, but support for other
|
16
|
+
SCMs like [Subversion][svn] is planned.
|
17
|
+
|
18
|
+
Getting Started
|
19
|
+
===============
|
20
|
+
|
21
|
+
Clone the source from our [git repository][repo]:
|
22
|
+
|
23
|
+
gem install dm-core dm-more data_objects do_sqlite3 bcrypt-ruby sinatra json haml xmpp4r-simple rcov hoe sr-sinatra-diddies --source=http://gems.github.com
|
24
|
+
git clone git://github.com/foca/integrity.git
|
25
|
+
cd integrity
|
26
|
+
git submodule update --init
|
27
|
+
cp config/config.sample.yml config/config.yml
|
28
|
+
$EDITOR config/config.yml
|
29
|
+
rake db:migrate
|
30
|
+
ruby app.rb
|
31
|
+
|
32
|
+
Now you can go to http://localhost:4567, add your first project, and enjoy
|
33
|
+
safer coding, with integrity.
|
34
|
+
|
35
|
+
If you want automatic commit processing, you currently need to be using
|
36
|
+
[GitHub][]. Click the edit link on your GitHub project, and add an integrity
|
37
|
+
link that looks like the following to the `Post-Receive URL` field:
|
38
|
+
|
39
|
+
http://integrity.domain.tld/projectname/push
|
40
|
+
|
41
|
+
Configuration
|
42
|
+
=============
|
43
|
+
|
44
|
+
The `config.yml` only needs two settings:
|
45
|
+
|
46
|
+
* `database_uri`: this should be a complete connection string to your database.
|
47
|
+
For example `mysql://user@localhost/integrity` (you need an `integrity` db
|
48
|
+
created in localhost, of course).
|
49
|
+
* `export_directory`: This is where your project's code will be checked out to.
|
50
|
+
Make sure it's writable by the user that runs Integrity.
|
51
|
+
|
52
|
+
Resources
|
53
|
+
========
|
54
|
+
|
55
|
+
We have a [Lighthouse account][lighthouse] where you can submit patches or
|
56
|
+
feature requests. Also, someone is usually around [#integrity][irc-channel] on
|
57
|
+
Freenode, so don't hesitate to stop by for ideas, help, patches or something.
|
58
|
+
|
59
|
+
Future plans
|
60
|
+
============
|
61
|
+
|
62
|
+
* [Twitter][]/[IRC][]/etc bots
|
63
|
+
* Other SCMs like Subversion
|
64
|
+
|
65
|
+
Development
|
66
|
+
===========
|
67
|
+
|
68
|
+
The code is stored in [GitHub][repo]. Feel free to fork, play with it, and send
|
69
|
+
a pull request afterwards.
|
70
|
+
|
71
|
+
In order to run the test suite you'll need a few more gems: [rspec][], [rcov][]
|
72
|
+
and [hpricot][]. With that installed running `rake` will run the specs and
|
73
|
+
ensure the code coverage stays high.
|
74
|
+
|
75
|
+
Thanks
|
76
|
+
======
|
77
|
+
|
78
|
+
Thanks to the fellowing people for their feedbacks, ideas and patches :
|
79
|
+
|
80
|
+
* [James Adam][james]
|
81
|
+
* [Elliott Cable][ec]
|
82
|
+
* [Corey Donohoe][atmos]
|
83
|
+
* [Kyle Hargraves][kyle]
|
84
|
+
* [Pier-Hugues Pellerin][ph]
|
85
|
+
* [Simon Rozet][sr]
|
86
|
+
* [Scott Taylor][scott]
|
87
|
+
|
88
|
+
[james]: http://github.com/lazyatom
|
89
|
+
[ec]: http://github.com/elliotcabble
|
90
|
+
[atmos]: http://github.com/atmos
|
91
|
+
[kyle]: http://github.com/pd
|
92
|
+
[ph]: http://github.com/ph
|
93
|
+
[sr]: http://purl.org/net/sr/
|
94
|
+
[scott]: http://github.com/smtlaissezfaire
|
95
|
+
|
96
|
+
License
|
97
|
+
=======
|
98
|
+
|
99
|
+
(The MIT License)
|
100
|
+
|
101
|
+
Copyright (c) 2008 [Nicolás Sanguinetti][foca], [entp][]
|
102
|
+
|
103
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
104
|
+
a copy of this software and associated documentation files (the
|
105
|
+
'Software'), to deal in the Software without restriction, including
|
106
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
107
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
108
|
+
permit persons to whom the Software is furnished to do so, subject to
|
109
|
+
the following conditions:
|
110
|
+
|
111
|
+
The above copyright notice and this permission notice shall be
|
112
|
+
included in all copies or substantial portions of the Software.
|
113
|
+
|
114
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
115
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
116
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
117
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
118
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
119
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
120
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
121
|
+
|
122
|
+
[Sinatra]: http://sinatrarb.com
|
123
|
+
[git]: http://git.or.cz
|
124
|
+
[svn]: http://subversion.tigris.org
|
125
|
+
[Twitter]: http://twitter.com
|
126
|
+
[IRC]: http://wikipedia.org/wiki/IRC
|
127
|
+
[entp]: http://entp.com
|
128
|
+
[GitHub]: http://github.com
|
129
|
+
|
130
|
+
[rspec]: http://rspec.info
|
131
|
+
[rcov]: http://eigenclass.org/hiki.rb?rcov
|
132
|
+
[hpricot]: http://code.whytheluckystiff.net/hpricot
|
133
|
+
|
134
|
+
[repo]: http://github.com/foca/integrity
|
135
|
+
[lighthouse]: http://integrity.lighthouseapp.com/projects/14308-integrity
|
136
|
+
[irc-channel]: irc://irc.freenode.net/integrity
|
137
|
+
|
138
|
+
[sr]: http://atonie.org/
|
139
|
+
[foca]: http://nicolassanguinetti.info/
|
data/Rakefile
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
require File.dirname(__FILE__) + "/lib/integrity"
|
2
|
+
require 'spec/rake/spectask'
|
3
|
+
require 'spec/rake/verify_rcov'
|
4
|
+
|
5
|
+
task :default => ["spec:coverage", "spec:coverage:verify"]
|
6
|
+
|
7
|
+
Spec::Rake::SpecTask.new(:spec) do |t|
|
8
|
+
t.spec_opts = ["--color", "--format", "progress"]
|
9
|
+
t.spec_files = Dir['spec/**/*_spec.rb'].sort
|
10
|
+
t.libs = ['lib']
|
11
|
+
t.rcov = false
|
12
|
+
end
|
13
|
+
|
14
|
+
namespace :spec do
|
15
|
+
Spec::Rake::SpecTask.new(:coverage) do |t|
|
16
|
+
t.spec_opts = ["--color", "--format", "progress"]
|
17
|
+
t.spec_files = Dir['spec/**/*_spec.rb'].sort
|
18
|
+
t.libs = ['lib']
|
19
|
+
t.rcov = true
|
20
|
+
t.rcov_opts = ['--exclude-only', '".*"', '--include-file', '^lib']
|
21
|
+
end
|
22
|
+
|
23
|
+
namespace :coverage do
|
24
|
+
RCov::VerifyTask.new(:verify) do |t|
|
25
|
+
t.threshold = 100
|
26
|
+
t.index_html = "coverage" / 'index.html'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
namespace :db do
|
33
|
+
desc "Setup connection."
|
34
|
+
task :connect do
|
35
|
+
ENV['CONFIG'] ? Integrity.new(ENV['CONFIG']) : Integrity.new
|
36
|
+
end
|
37
|
+
|
38
|
+
desc "Automigrate the database"
|
39
|
+
task :migrate => :connect do
|
40
|
+
require "project"
|
41
|
+
require "build"
|
42
|
+
require "notifier"
|
43
|
+
DataMapper.auto_migrate!
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
namespace :gem do
|
48
|
+
desc "Generate the gemspec at the root dir"
|
49
|
+
task :gemspec do
|
50
|
+
files = `git ls-files`.split("\n").reject {|f| f =~ %r(^spec) || f =~ %r(^vendor/rspec) || f =~ /^\.git/ }
|
51
|
+
files += %w(spec/spec_helper.rb spec/form_field_matchers.rb)
|
52
|
+
|
53
|
+
gemspec = <<-GEM
|
54
|
+
Gem::Specification.new do |s|
|
55
|
+
s.name = 'integrity'
|
56
|
+
s.version = '#{Integrity::VERSION}'
|
57
|
+
s.date = '#{Date.today.to_s}'
|
58
|
+
s.summary = 'The easy and fun Continuous Integration server'
|
59
|
+
s.description = 'Your Friendly Continuous Integration server. Easy, fun and painless!'
|
60
|
+
s.homepage = 'http://integrityapp.com'
|
61
|
+
s.rubyforge_project = 'integrity'
|
62
|
+
s.email = 'contacto@nicolassanguinetti.info'
|
63
|
+
s.authors = ['Nicolás Sanguinetti', 'Simon Rozet']
|
64
|
+
s.has_rdoc = false
|
65
|
+
s.executables = ['integrity']
|
66
|
+
s.files = %w(
|
67
|
+
#{files.join("\n" + " " * 26)}
|
68
|
+
)
|
69
|
+
|
70
|
+
s.add_dependency 'sinatra', ['>= 0.3.2']
|
71
|
+
s.add_dependency 'dm-core', ['>= 0.9.5']
|
72
|
+
s.add_dependency 'dm-validations', ['>= 0.9.5']
|
73
|
+
s.add_dependency 'dm-types', ['>= 0.9.5']
|
74
|
+
s.add_dependency 'dm-timestamps', ['>= 0.9.5']
|
75
|
+
s.add_dependency 'dm-aggregates', ['>= 0.9.5']
|
76
|
+
s.add_dependency 'data_objects', ['>= 0.9.5']
|
77
|
+
s.add_dependency 'do_sqlite3', ['>= 0.9.5']
|
78
|
+
s.add_dependency 'json'
|
79
|
+
s.add_dependency 'foca-sinatra-diddies', ['>= 0.0.2']
|
80
|
+
s.add_dependency 'rspec_hpricot_matchers'
|
81
|
+
s.add_dependency 'thor'
|
82
|
+
end
|
83
|
+
GEM
|
84
|
+
|
85
|
+
File.open(Integrity.root / "integrity.gemspec", "w") {|f| f.puts gemspec }
|
86
|
+
end
|
87
|
+
end
|
data/app.rb
ADDED
@@ -0,0 +1,247 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/lib/integrity'
|
2
|
+
require 'sinatra'
|
3
|
+
require 'diddies'
|
4
|
+
require 'hacks'
|
5
|
+
|
6
|
+
set :root, Integrity.root
|
7
|
+
set :public, Integrity.root / "public"
|
8
|
+
set :views, Integrity.root / "views"
|
9
|
+
|
10
|
+
enable :sessions
|
11
|
+
|
12
|
+
include Integrity
|
13
|
+
|
14
|
+
configure do
|
15
|
+
Integrity.new
|
16
|
+
end
|
17
|
+
|
18
|
+
not_found do
|
19
|
+
status 404
|
20
|
+
show :not_found, :title => "lost, are we?"
|
21
|
+
end
|
22
|
+
|
23
|
+
before do
|
24
|
+
# The browser only sends http auth data for requests that are explicitly
|
25
|
+
# required to do so. This way we get the real values of +#logged_in?+ and
|
26
|
+
# +#current_user+
|
27
|
+
login_required if session[:user]
|
28
|
+
end
|
29
|
+
|
30
|
+
get "/" do
|
31
|
+
@projects = Project.all(authorized? ? {} : { :public => true })
|
32
|
+
show :home, :title => "projects"
|
33
|
+
end
|
34
|
+
|
35
|
+
get "/login" do
|
36
|
+
login_required
|
37
|
+
session[:user] = current_user
|
38
|
+
redirect "/"
|
39
|
+
end
|
40
|
+
|
41
|
+
get "/new" do
|
42
|
+
login_required
|
43
|
+
|
44
|
+
@project = Project.new
|
45
|
+
show :new, :title => ["projects", "new project"]
|
46
|
+
end
|
47
|
+
|
48
|
+
post "/" do
|
49
|
+
login_required
|
50
|
+
|
51
|
+
@project = Project.new(params[:project_data])
|
52
|
+
if @project.save
|
53
|
+
@project.enable_notifiers(params["enabled_notifiers[]"], params["notifiers"])
|
54
|
+
redirect project_url(@project)
|
55
|
+
else
|
56
|
+
show :new, :title => ["projects", "new project"]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
get "/:project" do
|
61
|
+
login_required unless current_project.public?
|
62
|
+
show :project, :title => ["projects", current_project.permalink]
|
63
|
+
end
|
64
|
+
|
65
|
+
put "/:project" do
|
66
|
+
login_required
|
67
|
+
|
68
|
+
if current_project.update_attributes(params[:project_data])
|
69
|
+
current_project.enable_notifiers(params["enabled_notifiers[]"], params["notifiers"])
|
70
|
+
redirect project_url(current_project)
|
71
|
+
else
|
72
|
+
show :new, :title => ["projects", current_project.permalink, "edit"]
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
delete "/:project" do
|
77
|
+
login_required
|
78
|
+
|
79
|
+
current_project.destroy
|
80
|
+
redirect "/"
|
81
|
+
end
|
82
|
+
|
83
|
+
get "/:project/edit" do
|
84
|
+
login_required
|
85
|
+
|
86
|
+
show :new, :title => ["projects", current_project.permalink, "edit"]
|
87
|
+
end
|
88
|
+
|
89
|
+
post "/:project/push" do
|
90
|
+
content_type 'text/plain'
|
91
|
+
|
92
|
+
begin
|
93
|
+
payload = JSON.parse(params[:payload] || "")
|
94
|
+
payload['commits'].each do |commit|
|
95
|
+
current_project.build(commit['id']) if payload['ref'] =~ /#{current_project.branch}/
|
96
|
+
end
|
97
|
+
'Thanks, build started.'
|
98
|
+
rescue JSON::ParserError => exception
|
99
|
+
invalid_payload!(exception.to_s)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
post "/:project/builds" do
|
104
|
+
login_required
|
105
|
+
|
106
|
+
current_project.build
|
107
|
+
redirect project_url(@project)
|
108
|
+
end
|
109
|
+
|
110
|
+
get '/:project/builds/:build' do
|
111
|
+
login_required unless current_project.public?
|
112
|
+
show :build, :title => ["projects", current_project.permalink, current_build.short_commit_identifier]
|
113
|
+
end
|
114
|
+
|
115
|
+
get "/integrity.css" do
|
116
|
+
header "Content-Type" => "text/css; charset=utf-8"
|
117
|
+
sass :integrity
|
118
|
+
end
|
119
|
+
|
120
|
+
helpers do
|
121
|
+
include Rack::Utils
|
122
|
+
include Sinatra::Authorization
|
123
|
+
alias_method :h, :escape_html
|
124
|
+
|
125
|
+
def authorization_realm
|
126
|
+
"Integrity"
|
127
|
+
end
|
128
|
+
|
129
|
+
def authorized?
|
130
|
+
return true unless Integrity.config[:use_basic_auth]
|
131
|
+
!!request.env['REMOTE_USER']
|
132
|
+
end
|
133
|
+
|
134
|
+
def authorize(user, password)
|
135
|
+
if Integrity.config[:hash_admin_password]
|
136
|
+
password = Digest::SHA1.hexdigest(password)
|
137
|
+
end
|
138
|
+
|
139
|
+
!Integrity.config[:use_basic_auth] ||
|
140
|
+
(Integrity.config[:admin_username] == user &&
|
141
|
+
Integrity.config[:admin_password] == password)
|
142
|
+
end
|
143
|
+
|
144
|
+
def unauthorized!(realm=authorization_realm)
|
145
|
+
header 'WWW-Authenticate' => %(Basic realm="#{realm}")
|
146
|
+
throw :halt, [401, show(:unauthorized, :title => "incorrect credentials")]
|
147
|
+
end
|
148
|
+
|
149
|
+
def invalid_payload!(msg=nil)
|
150
|
+
throw :halt, [422, msg || 'No payload given']
|
151
|
+
end
|
152
|
+
|
153
|
+
def current_project
|
154
|
+
@project ||= Project.first(:permalink => params[:project]) or raise Sinatra::NotFound
|
155
|
+
end
|
156
|
+
|
157
|
+
def current_build
|
158
|
+
@build ||= current_project.builds.first(:commit_identifier => params[:build]) or raise Sinatra::NotFound
|
159
|
+
end
|
160
|
+
|
161
|
+
def show(view, options={})
|
162
|
+
@title = breadcrumbs(*options[:title])
|
163
|
+
haml view
|
164
|
+
end
|
165
|
+
|
166
|
+
def pages
|
167
|
+
@pages ||= [["projects", "/"], ["new project", "/new"]]
|
168
|
+
end
|
169
|
+
|
170
|
+
def breadcrumbs(*crumbs)
|
171
|
+
crumbs[0..-2].map do |crumb|
|
172
|
+
if page_data = pages.detect {|c| c.first == crumb }
|
173
|
+
%Q(<a href="#{page_data.last}">#{page_data.first}</a>)
|
174
|
+
elsif @project && @project.permalink == crumb
|
175
|
+
%Q(<a href="#{project_url(@project)}">#{@project.permalink}</a>)
|
176
|
+
end
|
177
|
+
end + [crumbs.last]
|
178
|
+
end
|
179
|
+
|
180
|
+
def cycle(*values)
|
181
|
+
@cycles ||= {}
|
182
|
+
@cycles[values] ||= -1 # first value returned is 0
|
183
|
+
next_value = @cycles[values] = (@cycles[values] + 1) % values.size
|
184
|
+
values[next_value]
|
185
|
+
end
|
186
|
+
|
187
|
+
def project_url(project, *path)
|
188
|
+
"/" << [project.permalink, *path].join("/")
|
189
|
+
end
|
190
|
+
|
191
|
+
def push_url_for(project)
|
192
|
+
Addressable::URI.parse(Integrity.config[:base_uri]).join("#{project_url(project)}/push").to_s
|
193
|
+
end
|
194
|
+
|
195
|
+
def build_url(build)
|
196
|
+
"/#{build.project.permalink}/builds/#{build.commit_identifier}"
|
197
|
+
end
|
198
|
+
|
199
|
+
def filter_attributes_of(model)
|
200
|
+
valid = model.properties.collect {|p| p.name.to_s }
|
201
|
+
Hash[*params.dup.select {|k,_| valid.include?(k) }.flatten]
|
202
|
+
end
|
203
|
+
|
204
|
+
def errors_on(object, field)
|
205
|
+
return "" unless errors = object.errors.on(field)
|
206
|
+
errors.map {|e| e.gsub(/#{field} /i, "") }.join(", ")
|
207
|
+
end
|
208
|
+
|
209
|
+
def error_class(object, field)
|
210
|
+
object.errors.on(field).nil? ? "" : "with_errors"
|
211
|
+
end
|
212
|
+
|
213
|
+
def checkbox(name, condition, extras={})
|
214
|
+
attrs = { :name => name, :type => "checkbox" }.merge(condition ? { :checked => "checked" } : {})
|
215
|
+
attrs.merge(extras)
|
216
|
+
end
|
217
|
+
|
218
|
+
def bash_color_codes(string)
|
219
|
+
string.gsub("\e[0m", '</span>').
|
220
|
+
gsub("\e[31m", '<span class="color31">').
|
221
|
+
gsub("\e[32m", '<span class="color32">').
|
222
|
+
gsub("\e[33m", '<span class="color33">').
|
223
|
+
gsub("\e[34m", '<span class="color34">').
|
224
|
+
gsub("\e[35m", '<span class="color35">').
|
225
|
+
gsub("\e[36m", '<span class="color36">').
|
226
|
+
gsub("\e[37m", '<span class="color37">')
|
227
|
+
end
|
228
|
+
|
229
|
+
def pretty_date(date_time)
|
230
|
+
today = Date.today
|
231
|
+
if date_time.day == today.day && date_time.month == today.month && date_time.year == today.year
|
232
|
+
"today"
|
233
|
+
elsif date_time.day == today.day - 1 && date_time.month == today.month && date_time.year == today.year
|
234
|
+
"yesterday"
|
235
|
+
else
|
236
|
+
date_time.strftime("on %b %d%o")
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def notifier_form(notifier)
|
241
|
+
haml(notifier.to_haml, :layout => :notifier, :locals => {
|
242
|
+
:config => current_project.config_for(notifier),
|
243
|
+
:notifier => "#{notifier.to_s.split(/::/).last}",
|
244
|
+
:enabled => current_project.notifies?(notifier)
|
245
|
+
})
|
246
|
+
end
|
247
|
+
end
|