codefumes 0.1.10 → 0.2.0
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/Gemfile +19 -0
- data/Gemfile.lock +135 -0
- data/History.txt +12 -0
- data/LICENSE +20 -0
- data/Manifest.txt +40 -19
- data/README.txt +11 -29
- data/Rakefile +15 -10
- data/bin/fumes +214 -0
- data/config/website.yml +2 -0
- data/cucumber.yml +2 -0
- data/features/claiming_a_project.feature +46 -0
- data/features/deleting_a_project.feature +32 -0
- data/features/releasing_a_project.feature +50 -0
- data/features/step_definitions/cli_steps.rb +98 -0
- data/features/step_definitions/common_steps.rb +168 -0
- data/features/step_definitions/filesystem_steps.rb +19 -0
- data/features/storing_user_api_key.feature +41 -0
- data/features/support/common.rb +29 -0
- data/features/support/env.rb +24 -0
- data/features/support/matchers.rb +11 -0
- data/features/synchronizing_repository_with_project.feature +33 -0
- data/lib/codefumes.rb +10 -8
- data/lib/codefumes/api.rb +20 -11
- data/lib/codefumes/api/build.rb +139 -0
- data/lib/codefumes/api/claim.rb +74 -0
- data/lib/codefumes/api/commit.rb +150 -0
- data/lib/codefumes/api/payload.rb +93 -0
- data/lib/codefumes/api/project.rb +158 -0
- data/lib/codefumes/cli_helpers.rb +54 -0
- data/lib/codefumes/config_file.rb +3 -2
- data/lib/codefumes/errors.rb +21 -0
- data/lib/codefumes/exit_codes.rb +10 -0
- data/lib/codefumes/harvester.rb +113 -0
- data/lib/codefumes/quick_build.rb +43 -0
- data/lib/codefumes/quick_metric.rb +20 -0
- data/lib/codefumes/source_control.rb +137 -0
- data/lib/integrity_notifier/codefumes.haml +11 -0
- data/lib/integrity_notifier/codefumes.rb +62 -0
- data/spec/codefumes/{build_spec.rb → api/build_spec.rb} +14 -24
- data/spec/codefumes/{claim_spec.rb → api/claim_spec.rb} +42 -3
- data/spec/codefumes/{commit_spec.rb → api/commit_spec.rb} +34 -24
- data/spec/codefumes/api/payload_spec.rb +148 -0
- data/spec/codefumes/api/project_spec.rb +286 -0
- data/spec/codefumes/api_spec.rb +38 -15
- data/spec/codefumes/config_file_spec.rb +69 -13
- data/spec/codefumes/harvester_spec.rb +118 -0
- data/spec/codefumes/source_control_spec.rb +199 -0
- data/spec/codefumes_service_helpers.rb +23 -19
- data/spec/fixtures/sample_project_dirs/no_scm/description +4 -0
- data/spec/spec_helper.rb +1 -0
- data/tasks/cucumber.rake +11 -0
- metadata +145 -60
- data/bin/cf_claim_project +0 -9
- data/bin/cf_release_project +0 -10
- data/bin/cf_store_credentials +0 -10
- data/lib/cf_claim_project/cli.rb +0 -95
- data/lib/cf_release_project/cli.rb +0 -76
- data/lib/cf_store_credentials/cli.rb +0 -50
- data/lib/codefumes/build.rb +0 -131
- data/lib/codefumes/claim.rb +0 -57
- data/lib/codefumes/commit.rb +0 -144
- data/lib/codefumes/payload.rb +0 -103
- data/lib/codefumes/project.rb +0 -129
- data/spec/cf_claim_project/cli_spec.rb +0 -17
- data/spec/cf_release_project/cli_spec.rb +0 -41
- data/spec/cf_store_credentials/cli_spec.rb +0 -28
- data/spec/codefumes/payload_spec.rb +0 -155
- data/spec/codefumes/project_spec.rb +0 -274
@@ -0,0 +1,93 @@
|
|
1
|
+
module CodeFumes
|
2
|
+
module API
|
3
|
+
# Payloads are intended to simplify sending up large amounts of
|
4
|
+
# content at one time. For example, when sending up the entire
|
5
|
+
# history of a repository, making a POST request for each commit would
|
6
|
+
# require a very large number of requests. Using a Payload object
|
7
|
+
# allows larger amounts of content to be saved at one time,
|
8
|
+
# significantly reducing the number of requests made.
|
9
|
+
class Payload
|
10
|
+
PAYLOAD_CHARACTER_LIMIT = 4000 #:nodoc:
|
11
|
+
|
12
|
+
attr_reader :project, :created_at
|
13
|
+
|
14
|
+
# +:commit_data+ should be a list of commits and associated data.
|
15
|
+
# An example would be:
|
16
|
+
#
|
17
|
+
# [{:identifier => "commit_identifer", :files_affected => 3,
|
18
|
+
# :custom_attributes => {:any_metric_you_want => "value"}}]
|
19
|
+
def initialize(project, commit_data)
|
20
|
+
@project = project
|
21
|
+
@content = {:commits => commit_data}
|
22
|
+
end
|
23
|
+
|
24
|
+
# Saves instance to CodeFumes.com. After a successful save, the
|
25
|
+
# +created_at+ attribute will be populated with the timestamp the
|
26
|
+
# Payload was created.
|
27
|
+
#
|
28
|
+
# Returns +true+ if the Payload does not contain any content to be
|
29
|
+
# saved or the request was successful.
|
30
|
+
#
|
31
|
+
# Returns +false+ if the request failed.
|
32
|
+
def save
|
33
|
+
return true if empty_payload?
|
34
|
+
response = API.post("/projects/#{@project.public_key}/payloads", :query => {:payload => @content}, :basic_auth => {:username => @project.public_key, :password => @project.private_key})
|
35
|
+
|
36
|
+
case response.code
|
37
|
+
when 201
|
38
|
+
@created_at = response['payload']['created_at']
|
39
|
+
true
|
40
|
+
else
|
41
|
+
false
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# +save+ requests are made with a standard POST request (not a
|
46
|
+
# multi-part POST), so the request size is limited by the
|
47
|
+
# application server. The current configuration on CodeFumes.com
|
48
|
+
# limits requests to approximately 8,000 bytes (a little over). In
|
49
|
+
# order to simplify dealing with these constraints, without
|
50
|
+
# requiring a multi-part POST request, +prepare+ can be used to
|
51
|
+
# "chunk" up the data into Payloads which do not exceed this limit.
|
52
|
+
#
|
53
|
+
# Returns collection of payload objects which fall into the
|
54
|
+
# constraints of a individual payload (ie: length of raw request,
|
55
|
+
# et cetera).
|
56
|
+
#--
|
57
|
+
# TODO: Clean up how the size of the request is constrained, this
|
58
|
+
# is pretty hackish right now (basically guesses how many
|
59
|
+
# characters would be added when HTTParty wraps the content in XML.)
|
60
|
+
def self.prepare(project, commit_data = {})
|
61
|
+
return [] if commit_data.nil? || commit_data.empty?
|
62
|
+
raw_payload = commit_data.dup
|
63
|
+
|
64
|
+
content = raw_payload[:commits]
|
65
|
+
initial_chunks = {:on_deck => [], :prepared => []}
|
66
|
+
|
67
|
+
# TODO: Clean this up
|
68
|
+
chunked = content.inject(initial_chunks) do |chunks, new_commit|
|
69
|
+
if chunks[:on_deck].to_s.length + new_commit.to_s.length >= PAYLOAD_CHARACTER_LIMIT
|
70
|
+
chunks[:prepared] << chunks[:on_deck]
|
71
|
+
chunks[:on_deck] = [new_commit]
|
72
|
+
elsif new_commit == content.last
|
73
|
+
chunks[:on_deck] << new_commit
|
74
|
+
chunks[:prepared] << chunks[:on_deck]
|
75
|
+
chunks[:on_deck] = []
|
76
|
+
else
|
77
|
+
chunks[:on_deck] << new_commit
|
78
|
+
end
|
79
|
+
chunks
|
80
|
+
end
|
81
|
+
|
82
|
+
chunked[:prepared].map do |raw_content|
|
83
|
+
Payload.new(project, raw_content)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
def empty_payload?
|
89
|
+
@content.nil? || @content.empty? || @content[:commits].nil? || @content[:commits].blank?
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
module CodeFumes
|
2
|
+
module API
|
3
|
+
# A Project encapsulates the concept of a project on the CodeFumes.com
|
4
|
+
# website. Each project has a public key, private key, and can have a
|
5
|
+
# name defined. Projects are also associated with a collection of
|
6
|
+
# commits from a repository.
|
7
|
+
class Project
|
8
|
+
attr_reader :private_key, :short_uri, :community_uri, :api_uri, :build_status
|
9
|
+
attr_accessor :name, :public_key
|
10
|
+
|
11
|
+
def initialize(public_key=nil, private_key = nil, options = {})
|
12
|
+
@public_key = public_key
|
13
|
+
@private_key = private_key
|
14
|
+
@name = options['name'] || options[:name]
|
15
|
+
@short_uri = options['short_uri'] || options[:short_uri]
|
16
|
+
@community_uri = options['community_uri'] || options[:community_uri]
|
17
|
+
@api_uri = options['api_uri'] || options[:api_uri]
|
18
|
+
@build_status = options['build_status'] || options[:build_status]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Creates new project
|
22
|
+
def self.create
|
23
|
+
response = API.post('/projects')
|
24
|
+
|
25
|
+
case response.code
|
26
|
+
when 201
|
27
|
+
new.reinitialize_from_hash!(response['project'])
|
28
|
+
else
|
29
|
+
false
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Deletes project from the website. You must have both the +public_key+
|
34
|
+
# and +private_key+ of a project in order to delete it.
|
35
|
+
#
|
36
|
+
# Returns +true+ if the request succeeded.
|
37
|
+
#
|
38
|
+
# Returns +false+ if the request failed.
|
39
|
+
def delete
|
40
|
+
if public_key.nil? || private_key.nil?
|
41
|
+
msg = "You must have both the private key & public key of a project in order to delete it. (currently: {:private_key => '#{private_key.to_s}', :public_key => '#{public_key.to_s}'}"
|
42
|
+
raise Errors::InsufficientCredentials, msg
|
43
|
+
end
|
44
|
+
|
45
|
+
response = destroy!
|
46
|
+
case response.code
|
47
|
+
when 200
|
48
|
+
return true
|
49
|
+
else
|
50
|
+
return false
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Attempts to save current state of project to CodeFumes.
|
55
|
+
#
|
56
|
+
# Returns +true+ if the request succeeded.
|
57
|
+
#
|
58
|
+
# Returns +false+ if the request failed.
|
59
|
+
def save
|
60
|
+
response = API.put("/projects/#{public_key}", :query => {:project => {:name => name}},
|
61
|
+
:basic_auth => {:username => public_key, :password => private_key})
|
62
|
+
|
63
|
+
case response.code
|
64
|
+
when 200
|
65
|
+
reinitialize_from_hash!(response['project'])
|
66
|
+
true
|
67
|
+
else
|
68
|
+
false
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Serializes a Project instance to a format compatible with the
|
73
|
+
# CodeFumes config file.
|
74
|
+
def to_config
|
75
|
+
project_attributes = {:api_uri => api_uri, :short_uri => short_uri}
|
76
|
+
project_attributes[:private_key] = private_key unless private_key.nil?
|
77
|
+
{public_key.to_sym => project_attributes}
|
78
|
+
end
|
79
|
+
|
80
|
+
# Searches website for project with the supplied public key.
|
81
|
+
#
|
82
|
+
# Returns a Project instance if the project exists and is available,
|
83
|
+
# to the user making the request.
|
84
|
+
#
|
85
|
+
# Returns +nil+ in all other cases.
|
86
|
+
def self.find(public_key)
|
87
|
+
return nil if public_key.nil? || public_key.to_s.empty?
|
88
|
+
response = API.get("/projects/#{public_key}")
|
89
|
+
case response.code
|
90
|
+
when 200
|
91
|
+
project = Project.new
|
92
|
+
project.reinitialize_from_hash!(response['project'])
|
93
|
+
else
|
94
|
+
nil
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Attempts to claim "ownership" of the project using the API key
|
99
|
+
# defined in the "credentials" section of your CodeFumes config
|
100
|
+
# file.
|
101
|
+
#
|
102
|
+
# If you need to claim a project for a key that is not defined in
|
103
|
+
# your config file, refer to Claim#create.
|
104
|
+
#
|
105
|
+
# Returns true if the request is successful.
|
106
|
+
#
|
107
|
+
# Returns +false+ in all other cases.
|
108
|
+
def claim
|
109
|
+
Claim.create(self, ConfigFile.api_key)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Attempts to relinquish "ownership" of the project using the API key
|
113
|
+
# defined in the "credentials" section of your CodeFumes config
|
114
|
+
# file.
|
115
|
+
#
|
116
|
+
# If you need to relinquish ownership of a project for a key that is
|
117
|
+
# not defined in your config file, refer to Claim#delete.
|
118
|
+
#
|
119
|
+
# Returns true if the request is successful.
|
120
|
+
#
|
121
|
+
# Returns +false+ in all other cases.
|
122
|
+
def release
|
123
|
+
Claim.destroy(self, ConfigFile.api_key)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Overrides existing attributes with those supplied in +options+. This
|
127
|
+
# simplifies the process of updating an object's state when given a response
|
128
|
+
# from the CodeFumes API.
|
129
|
+
#
|
130
|
+
# Valid options are:
|
131
|
+
# * name
|
132
|
+
# * public_key
|
133
|
+
# * private_key
|
134
|
+
# * short_uri
|
135
|
+
# * community_uri
|
136
|
+
# * api_uri
|
137
|
+
# * build_status
|
138
|
+
#
|
139
|
+
# Returns +self+
|
140
|
+
def reinitialize_from_hash!(options = {}) #:nodoc:
|
141
|
+
@name = options['name'] || options[:name]
|
142
|
+
@public_key = options['public_key'] || options[:public_key]
|
143
|
+
@private_key = options['private_key'] || options[:private_key]
|
144
|
+
@short_uri = options['short_uri'] || options[:short_uri]
|
145
|
+
@community_uri = options['community_uri'] || options[:community_uri]
|
146
|
+
@api_uri = options['api_uri'] || options[:api_uri]
|
147
|
+
@build_status = options['build_status'] || options[:build_status]
|
148
|
+
self
|
149
|
+
end
|
150
|
+
|
151
|
+
|
152
|
+
private
|
153
|
+
def destroy!
|
154
|
+
API.delete("/projects/#{@public_key}", :basic_auth => {:username => @public_key, :password => @private_key})
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module CodeFumes
|
2
|
+
# Module with convenience methods used in 'fumes' command line executable
|
3
|
+
module CLIHelpers #nodoc
|
4
|
+
def public_keys_specified(options)
|
5
|
+
return ConfigFile.public_keys if options[:all]
|
6
|
+
[options[:public_key] || SourceControl.new('./').public_key]
|
7
|
+
end
|
8
|
+
|
9
|
+
def print_api_mode_notification
|
10
|
+
puts "NOTE: Sending all requests & data to non-production server! (#{API.base_uri})"
|
11
|
+
end
|
12
|
+
|
13
|
+
def issue_project_commands(message, public_keys, &block)
|
14
|
+
public_keys.each do |public_key|
|
15
|
+
print "#{message}...'#{public_key}': "
|
16
|
+
project = Project.find(public_key.to_s)
|
17
|
+
yield(project)
|
18
|
+
end
|
19
|
+
|
20
|
+
puts ""
|
21
|
+
puts "Done!"
|
22
|
+
end
|
23
|
+
|
24
|
+
def wrap_with_standard_feedback(project, &block)
|
25
|
+
if project.nil?
|
26
|
+
puts "Project Not Found."
|
27
|
+
elsif yield != true
|
28
|
+
puts "Denied."
|
29
|
+
else
|
30
|
+
puts "Success!"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def command_doesnt_use_api?(command)
|
35
|
+
[:'api-key'].include?(command.name)
|
36
|
+
end
|
37
|
+
|
38
|
+
# lifted from the Github gem
|
39
|
+
def has_launchy?(&block)
|
40
|
+
begin
|
41
|
+
gem 'launchy'
|
42
|
+
require 'launchy'
|
43
|
+
block.call
|
44
|
+
rescue Gem::LoadError
|
45
|
+
raise Errors::MissingLaunchyGem, "'launchy' gem required, but missing"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# lifted from the Github gem
|
50
|
+
def open_in_browser(url, &block)
|
51
|
+
has_launchy? {Launchy::Browser.new.visit url}
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -67,9 +67,10 @@ module CodeFumes
|
|
67
67
|
|
68
68
|
# Returns a Hash representation of a specific project contained in
|
69
69
|
# the CodeFumes config file.
|
70
|
-
def options_for_project(
|
70
|
+
def options_for_project(project_or_key)
|
71
|
+
public_key = project_or_key.is_a?(Project) ? project_or_key.public_key : project_or_key
|
71
72
|
config = serialized
|
72
|
-
|
73
|
+
project_or_key && config[:projects] && config[:projects][public_key.to_sym] || {}
|
73
74
|
end
|
74
75
|
|
75
76
|
def public_keys
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module CodeFumes
|
2
|
+
module Errors #:nodoc:
|
3
|
+
class InsufficientCredentials < StandardError #:nodoc:
|
4
|
+
end
|
5
|
+
|
6
|
+
class UnsupportedScmToolError < StandardError #:nodoc:
|
7
|
+
end
|
8
|
+
|
9
|
+
class UnknownProjectError < StandardError #:nodoc:
|
10
|
+
end
|
11
|
+
|
12
|
+
class NoUserApiKeyError < ArgumentError #:nodoc:
|
13
|
+
end
|
14
|
+
|
15
|
+
class NoApiKeySpecified < ArgumentError #:nodoc:
|
16
|
+
end
|
17
|
+
|
18
|
+
class MissingLaunchyGem < Gem::LoadError #:nodoc:
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
module CodeFumes
|
2
|
+
# Simple class responsible for creating a project on the CodeFumes
|
3
|
+
# website, storing project information in the CodeFumes config file,
|
4
|
+
# and synchronizing a repository's commit history with CodeFumes.com.
|
5
|
+
#
|
6
|
+
# NOTE: Technically this can be used by anything (obviously), but it
|
7
|
+
# was written with the intention of being used with the
|
8
|
+
# +harvest_repo_metrics+ script, and is essentially geared for that
|
9
|
+
# scenario.
|
10
|
+
class Harvester
|
11
|
+
attr_reader :path
|
12
|
+
DEFAULT_PATH = './' #:nodoc:
|
13
|
+
|
14
|
+
# Accepts the following options:
|
15
|
+
# * +:path+ - the path of the repository to gather information from
|
16
|
+
# (Defaults to './').
|
17
|
+
# * +:public_key+ - Sets the public key of the project. This
|
18
|
+
# property will be read from the CodeFumes config file if one
|
19
|
+
# exists for the repository supplied at +:path+.
|
20
|
+
# * +:private_key+ - Sets the private key of the project. This
|
21
|
+
# property will be read from the CodeFumes config file if on
|
22
|
+
# exists for the repository supplied at +:path+.
|
23
|
+
# * +:name+ - Sets the name of the project on the CodeFumes site
|
24
|
+
#
|
25
|
+
# Note:
|
26
|
+
# Neither the +public_key+ nor +private_key+ is supported
|
27
|
+
# when creating a new project, but is used when updating an
|
28
|
+
# existing one. This prevents custom public keys from being
|
29
|
+
# created, but allows the user to share the public/private
|
30
|
+
# keys of a project with other users, or use them on other
|
31
|
+
# machines.
|
32
|
+
def initialize(passed_in_options = {})
|
33
|
+
options = passed_in_options.dup
|
34
|
+
@path = File.expand_path(options.delete(:path) || DEFAULT_PATH)
|
35
|
+
@repository = SourceControl.new(@path)
|
36
|
+
|
37
|
+
public_key = options[:public_key] || @repository.public_key
|
38
|
+
private_key = options[:private_key] || @repository.private_key
|
39
|
+
@project = initialize_project(public_key, private_key)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Creates or updates a project information on the CodeFumes site,
|
43
|
+
# synchronizes the repository's commit history, and prints the
|
44
|
+
# results to STDOUT.
|
45
|
+
#
|
46
|
+
# Returns a Hash containing the keys :successful_count and :total_count
|
47
|
+
# if the process succeeded and updates were posted to the server with
|
48
|
+
# their associated values.
|
49
|
+
#
|
50
|
+
# Returns and empty Hash if the local repository is in sync with the
|
51
|
+
# server and no updates were posted.
|
52
|
+
#
|
53
|
+
# Returns +false+ if the process failed.
|
54
|
+
def publish_data!
|
55
|
+
if @project.save
|
56
|
+
store_public_key_in_repository
|
57
|
+
update_codefumes_config_file
|
58
|
+
generate_and_save_payload || {}
|
59
|
+
else
|
60
|
+
false
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns the CodeFumes public key of the project that is located
|
65
|
+
# at the supplied path.
|
66
|
+
def public_key
|
67
|
+
@project.public_key
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns the CodeFumes private key of the project that is located
|
71
|
+
# at the supplied path.
|
72
|
+
def private_key
|
73
|
+
@project.private_key
|
74
|
+
end
|
75
|
+
|
76
|
+
# Returns the CodeFumes 'short uri' of the project that is located
|
77
|
+
# at the supplied path. The 'short uri' is similar to a Tiny URL for
|
78
|
+
# a project.
|
79
|
+
def short_uri
|
80
|
+
@project.short_uri
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
#TODO: Smarten this up a bit.
|
85
|
+
# - Handle nils more cleanly, etc.
|
86
|
+
# - Don't #create if _something_ was supplied. Verify it was valid or exit.
|
87
|
+
def initialize_project(public_key = nil, private_key = nil)
|
88
|
+
return Project.create if public_key.nil? || public_key.empty?
|
89
|
+
|
90
|
+
msg = "Project public key provided was not found via the API (supplied '#{public_key}')."
|
91
|
+
Project.find(public_key) || raise(Errors::UnknownProjectError, msg)
|
92
|
+
end
|
93
|
+
|
94
|
+
def store_public_key_in_repository
|
95
|
+
@repository.store_public_key(@project.public_key)
|
96
|
+
end
|
97
|
+
|
98
|
+
def update_codefumes_config_file
|
99
|
+
ConfigFile.save_project(@project)
|
100
|
+
end
|
101
|
+
|
102
|
+
def generate_and_save_payload
|
103
|
+
payload = @repository.payload(Commit.latest_identifier(@project), "HEAD")
|
104
|
+
if payload.empty?
|
105
|
+
nil
|
106
|
+
else
|
107
|
+
payloads = Payload.prepare(@project, payload)
|
108
|
+
successful_requests = payloads.select {|payload| payload.save == true}
|
109
|
+
{:successful_count => successful_requests.size, :total_count => payloads.size}
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|