ruby_vsts 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +3 -0
- data.tar.gz.sig +0 -0
- data/.codeclimate.yml +18 -0
- data/.gitignore +25 -0
- data/.rspec +1 -0
- data/.rubocop.yml +44 -0
- data/.yardopts +8 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +19 -0
- data/README.md +47 -0
- data/Rakefile +14 -0
- data/certs/ruby_vsts-gem-public_cert.pem +21 -0
- data/lib/ruby_vsts.rb +33 -0
- data/lib/vsts/api_client.rb +150 -0
- data/lib/vsts/api_response.rb +24 -0
- data/lib/vsts/base_model.rb +14 -0
- data/lib/vsts/change.rb +16 -0
- data/lib/vsts/changeset.rb +76 -0
- data/lib/vsts/configuration.rb +19 -0
- data/lib/vsts/identity.rb +16 -0
- data/lib/vsts/item.rb +34 -0
- data/lib/vsts/version.rb +3 -0
- data/ruby_vsts.gemspec +36 -0
- data/spec/fixtures/tfvc_changeset_by_id.json +36 -0
- data/spec/fixtures/tfvc_changeset_changes.json +13 -0
- data/spec/fixtures/tfvc_changesets_list.json +362 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/vsts/base_model_spec.rb +10 -0
- data/spec/vsts/change_spec.rb +25 -0
- data/spec/vsts/changeset_spec.rb +145 -0
- data/spec/vsts/configuration_spec.rb +43 -0
- data/spec/vsts/item_spec.rb +74 -0
- data/spec/vsts_spec.rb +15 -0
- metadata +253 -0
- metadata.gz.sig +1 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f231587e2fb121259d565337ac2563a0d903b383
|
4
|
+
data.tar.gz: 6767463306762c36b6989d39a6edc20e84d3db88
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1b5e22b6d5a3aa91e66fb8f752fc07beefa5dcc05653999586a9b878fbe79f1d0e16b8f9edbe2aacf76132ef52acbd759bc4efa6378c3076f7ed7aa5383817cb
|
7
|
+
data.tar.gz: f309cb3aa500a76dd3e16820a35cf5ef11d4cbd7ba83a73b59db3241a52d7d187461023722d86bde807671127b50e5969859798d66579e537c8d70f481e1567d
|
checksums.yaml.gz.sig
ADDED
data.tar.gz.sig
ADDED
Binary file
|
data/.codeclimate.yml
ADDED
data/.gitignore
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# Gem builds
|
2
|
+
pkg/
|
3
|
+
*.gem
|
4
|
+
|
5
|
+
# Bundler files
|
6
|
+
.bundle
|
7
|
+
Gemfile.lock
|
8
|
+
|
9
|
+
# Ignore Byebug command history file.
|
10
|
+
.byebug_history
|
11
|
+
|
12
|
+
# SimpleCov code coverage reports
|
13
|
+
/coverage
|
14
|
+
|
15
|
+
# CodeClimate repo token to report code coverage
|
16
|
+
# See https://codeclimate.com/repos/58ea190f4e4fc9029400296b/coverage_setup
|
17
|
+
.codeclimate_repo_token
|
18
|
+
|
19
|
+
# Yard documentation files
|
20
|
+
.yardoc/
|
21
|
+
doc/
|
22
|
+
|
23
|
+
# Temporary backups, unneeded files
|
24
|
+
*.old
|
25
|
+
*.orig
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
AllCops:
|
2
|
+
DisplayCopNames: true
|
3
|
+
|
4
|
+
Rails:
|
5
|
+
Enabled: false
|
6
|
+
|
7
|
+
Style/StringLiterals:
|
8
|
+
Enabled: false
|
9
|
+
# EnforcedStyle: double_quotes # if enabled, this can be single_quotes or double_quotes
|
10
|
+
|
11
|
+
Style/SymbolArray:
|
12
|
+
Enabled: false
|
13
|
+
|
14
|
+
Style/WordArray:
|
15
|
+
Enabled: false
|
16
|
+
|
17
|
+
Style/RegexpLiteral:
|
18
|
+
Enabled: false
|
19
|
+
|
20
|
+
Metrics/LineLength:
|
21
|
+
Max: 135
|
22
|
+
# To make it possible to copy or click on URIs in the code, we allow lines
|
23
|
+
# containing a URI to be longer than Max.
|
24
|
+
AllowHeredoc: true
|
25
|
+
AllowURI: true
|
26
|
+
URISchemes:
|
27
|
+
- http
|
28
|
+
- https
|
29
|
+
|
30
|
+
Metrics/MethodLength:
|
31
|
+
CountComments: false # count full line comments?
|
32
|
+
Max: 25
|
33
|
+
|
34
|
+
Metrics/AbcSize:
|
35
|
+
# The ABC size is a calculated magnitude, so this number can be a Fixnum or
|
36
|
+
# a Float.
|
37
|
+
Max: 40
|
38
|
+
|
39
|
+
Metrics/CyclomaticComplexity:
|
40
|
+
# even 15 may still be acceptable
|
41
|
+
Max: 10
|
42
|
+
|
43
|
+
Metrics/PerceivedComplexity:
|
44
|
+
Max: 10
|
data/.yardopts
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2017 Gabor Lengyel and Prodexity Ltd.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# ruby_vsts
|
2
|
+
An unofficial Microsoft Visual Studio Team Services (VSTS) API client in Ruby
|
3
|
+
|
4
|
+
[![Code Climate](https://codeclimate.com/github/prodexity/ruby_vsts.png)](https://codeclimate.com/github/prodexity/ruby_vsts)
|
5
|
+
[![Issue Count](https://codeclimate.com/github/prodexity/ruby_vsts/badges/issue_count.svg)](https://codeclimate.com/github/prodexity/ruby_vsts)
|
6
|
+
[![Test Coverage](https://codeclimate.com/github/prodexity/ruby_vsts/badges/coverage.svg)](https://codeclimate.com/github/prodexity/ruby_vsts/coverage)
|
7
|
+
|
8
|
+
## About
|
9
|
+
This will be a Ruby gem to connect to the Microsoft Visual Studio online (VSTS) Rest API.
|
10
|
+
It may also work with recent versions of TFS too. *Work is heavily in progress!*
|
11
|
+
|
12
|
+
## Usage
|
13
|
+
|
14
|
+
### Setup
|
15
|
+
```ruby
|
16
|
+
require 'ruby_vsts'
|
17
|
+
|
18
|
+
VSTS.configure do |config|
|
19
|
+
config.personal_access_token = "YOUR_PERSONAL_ACCESS_TOKEN"
|
20
|
+
config.base_url = "https://YOUR_INSTANCE.visualstudio.com/"
|
21
|
+
end
|
22
|
+
```
|
23
|
+
|
24
|
+
### Finding changesets
|
25
|
+
```ruby
|
26
|
+
VSTS::Changeset.find(72300) # find changeset by id
|
27
|
+
VSTS::Changeset.find_all(author: "fabrikam13@hotmail.com") # find by author
|
28
|
+
VSTS::Changeset.find_all(fromId: 1000, toId: 1200) # find by id range
|
29
|
+
VSTS::Changeset.find_all(fromDate: "03-01-2017", toDate: "03-18-2017-2:00PM") # find by date range
|
30
|
+
VSTS::Changeset.find_all(itemPath: "$/Fabrikam-Fiber-TFVC/Program.cs") # find by item path
|
31
|
+
VSTS::Changeset.find_all(top: 20, skip: 100) # paging
|
32
|
+
# ...
|
33
|
+
```
|
34
|
+
|
35
|
+
### Getting changes in a changeset
|
36
|
+
```ruby
|
37
|
+
changeset = VSTS::Changeset.find(72300)
|
38
|
+
changes = changeset.changes
|
39
|
+
```
|
40
|
+
|
41
|
+
### Getting change items
|
42
|
+
```ruby
|
43
|
+
item = changes[0]
|
44
|
+
file_contents = item.get # current version
|
45
|
+
```
|
46
|
+
|
47
|
+
Please see specs and the source code for further examples.
|
data/Rakefile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
require 'rspec/core/rake_task'
|
3
|
+
|
4
|
+
RSpec::Core::RakeTask.new(:test)
|
5
|
+
|
6
|
+
task test_and_report: [:test, :report_coverage]
|
7
|
+
|
8
|
+
task :report_coverage do
|
9
|
+
ENV["CODECLIMATE_REPO_TOKEN"] = File.read(".codeclimate_repo_token") if File.exist?(".codeclimate_repo_token")
|
10
|
+
`codeclimate-test-reporter`
|
11
|
+
end
|
12
|
+
|
13
|
+
desc "Run tests and report coverage to CodeClimate"
|
14
|
+
task default: :test_and_report
|
@@ -0,0 +1,21 @@
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
2
|
+
MIIDhTCCAm2gAwIBAgIBATANBgkqhkiG9w0BAQUFADBEMRIwEAYDVQQDDAlydWJ5
|
3
|
+
X3ZzdHMxGTAXBgoJkiaJk/IsZAEZFglwcm9kZXhpdHkxEzARBgoJkiaJk/IsZAEZ
|
4
|
+
FgNjb20wHhcNMTcwNDEyMDk1MzE1WhcNMTgwNDEyMDk1MzE1WjBEMRIwEAYDVQQD
|
5
|
+
DAlydWJ5X3ZzdHMxGTAXBgoJkiaJk/IsZAEZFglwcm9kZXhpdHkxEzARBgoJkiaJ
|
6
|
+
k/IsZAEZFgNjb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCnth7a
|
7
|
+
Innh/5+zT5rdwHMySmQG2qyuNh5ammn4ZleFvkZxDPpFb5Kn+BeoR8O3OWEe5WEs
|
8
|
+
4FUR/43Ow1HzYMIJIIb6MxNZyZQj6Bm+cKZPctL/h0KjC7kG+2aRdUCsfFKoZvMZ
|
9
|
+
69yHxtArNtbt6dIRsic8CHioQe0i6/7BVD/1OKKt600dt1K1zf7j7T8xWIdCwbO8
|
10
|
+
zWE4GpEBnEA4mBPMKqAZmd+DsqrbCWPie8TdfGeWo71/7rUuCbAUpMq4Rh3SpmfV
|
11
|
+
t0OQMShhuAaLoRLuIZ48hiC3ELAr9ZCK8rn5Ci2trLEVjyOvbq/QnwnGspydlyaN
|
12
|
+
xzGpH5p82DwcUP5TAgMBAAGjgYEwfzAJBgNVHRMEAjAAMAsGA1UdDwQEAwIEsDAd
|
13
|
+
BgNVHQ4EFgQUYZTJdcOOpive/ZTgo5EYhS98qUUwIgYDVR0RBBswGYEXcnVieV92
|
14
|
+
c3RzQHByb2RleGl0eS5jb20wIgYDVR0SBBswGYEXcnVieV92c3RzQHByb2RleGl0
|
15
|
+
eS5jb20wDQYJKoZIhvcNAQEFBQADggEBAFiM7TEAsjlwjCyCMlzw0Yq/igWgaaFP
|
16
|
+
of+iPXZUC49YMTpXnQjNl9sE+cxHzkJzyM00YdeJ18DDtquIZ44Hdd30J9oleJ5g
|
17
|
+
DgGCX4bCKg5WTBwqvd0ivATn9uxDxLF1VP/cl1MJPXhW8+Bhq2FzbyWvQuxHeFsI
|
18
|
+
jJNJdpaL4UgxvhYECxAd1gzvIpRFbSqJJZVr8T4tYjfxyaGJxf9T5GbtYazYcRmP
|
19
|
+
Pfk9fa2jjnmyUPewuJZHmwArB9oRryAdwWtOsvVZHZ1ulcmW+Pbo2IiYQHvl1zQH
|
20
|
+
Z5Mw91gFnDov+9F9be4W5sZmbj640vetlJGLdMFheEZ2HSX+4fEZu1k=
|
21
|
+
-----END CERTIFICATE-----
|
data/lib/ruby_vsts.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'vsts/version'
|
2
|
+
require 'vsts/configuration'
|
3
|
+
require 'vsts/api_client'
|
4
|
+
require 'vsts/api_response'
|
5
|
+
require 'vsts/base_model'
|
6
|
+
require 'vsts/identity'
|
7
|
+
require 'vsts/item'
|
8
|
+
require 'vsts/change'
|
9
|
+
require 'vsts/changeset'
|
10
|
+
|
11
|
+
# Base namespace for ruby_vsts
|
12
|
+
module VSTS
|
13
|
+
class << self
|
14
|
+
attr_accessor :configuration
|
15
|
+
attr_accessor :logger
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.configuration
|
19
|
+
@configuration ||= Configuration.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.reset
|
23
|
+
@configuration = Configuration.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.configure
|
27
|
+
yield(configuration)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.logger
|
31
|
+
@logger ||= Logger.new(STDOUT)
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'rest-client'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
# VSTS namespace
|
5
|
+
module VSTS
|
6
|
+
# API client for Visual Studio Team Services (VSTS)
|
7
|
+
# Manages access tokens and API versions, builds proper requests as expected by the VSTS API
|
8
|
+
class APIClient
|
9
|
+
# Make an API request
|
10
|
+
#
|
11
|
+
# @param method [Symbol] the method to be used, can be :get, :put, :post, :delete or :head (will be passed to RestClient)
|
12
|
+
# @param resource [String] the resource to request under the base_url (ie. "/changesets")
|
13
|
+
# @param opts [Hash] options for the request
|
14
|
+
# @option opts [Hash] :payload payload for the request (if any)
|
15
|
+
# @option opts [String] :api_version
|
16
|
+
# @option opts [String] :collection
|
17
|
+
# @option opts [String] :team_project
|
18
|
+
# @option opts [String] :area
|
19
|
+
# @option opts [Hash] :urlparams
|
20
|
+
# @return [Hash] request results as parsed from json
|
21
|
+
def self.request(method, resource, opts = {})
|
22
|
+
url = build_url(resource, opts)
|
23
|
+
VSTS.logger.debug("VSTS request: #{method} #{url}") if VSTS.configuration.debug
|
24
|
+
req = {
|
25
|
+
method: method,
|
26
|
+
url: url,
|
27
|
+
payload: opts[:payload],
|
28
|
+
headers: {
|
29
|
+
Authorization: authz_header_value,
|
30
|
+
Accept: "application/json",
|
31
|
+
"Content-Type" => "application/json"
|
32
|
+
}
|
33
|
+
}
|
34
|
+
resp = RestClient::Request.execute(req)
|
35
|
+
APIResponse.new(req, resp)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Helper method for GET requests, calls #request
|
39
|
+
#
|
40
|
+
# @param resource [String] the resource to request under the base_url (ie. "/changesets")
|
41
|
+
# @param opts [Hash] query options, see #request
|
42
|
+
# @return [Hash] request results as parsed from json
|
43
|
+
def self.get(resource, opts = {})
|
44
|
+
request(:get, resource, opts)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Helper method for POST requests, calls #request
|
48
|
+
#
|
49
|
+
# @param resource [String] the resource to request under the base_url (ie. "/changesets")
|
50
|
+
# @param payload [Hash] payload to be sent with the request, takes precedence over opts[:payload]
|
51
|
+
# @param opts [Hash] query options, see #request
|
52
|
+
# @return [Hash] request results as parsed from json
|
53
|
+
def self.post(resource, payload, opts = {})
|
54
|
+
opts[:payload] = payload
|
55
|
+
request(:post, resource, opts)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Helper method for PUT requests, calls #request
|
59
|
+
#
|
60
|
+
# @param resource [String] the resource to request under the base_url (ie. "/changesets")
|
61
|
+
# @param payload [Hash] payload to be sent with the request, takes precedence over opts[:payload]
|
62
|
+
# @param opts [Hash] query options, see #request
|
63
|
+
# @return [Hash] request results as parsed from json
|
64
|
+
def self.put(resource, payload, opts = {})
|
65
|
+
opts[:payload] = payload
|
66
|
+
request(:put, resource, opts)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Helper method for PATCH requests, calls #request
|
70
|
+
#
|
71
|
+
# @param resource [String] the resource to request under the base_url (ie. "/changesets")
|
72
|
+
# @param payload [Hash] payload to be sent with the request, takes precedence over opts[:payload]
|
73
|
+
# @param opts [Hash] query options, see #request
|
74
|
+
# @return [Hash] request results as parsed from json
|
75
|
+
def self.patch(resource, payload, opts = {})
|
76
|
+
opts[:payload] = payload
|
77
|
+
request(:patch, resource, opts)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Helper method for DELETE requests, calls #request
|
81
|
+
#
|
82
|
+
# @param resource [String] the resource to request under the base_url (ie. "/changesets")
|
83
|
+
# @param opts [Hash] query options, see #request
|
84
|
+
# @return [Hash] request results as parsed from json
|
85
|
+
def self.delete(resource, opts = {})
|
86
|
+
request(:delete, resource, opts)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Private class methods
|
90
|
+
|
91
|
+
# Builds VSTS url as described in https://www.visualstudio.com/en-us/docs/integrate/get-started/rest/basics
|
92
|
+
#
|
93
|
+
# @param resource [String] the VSTS resource
|
94
|
+
# @param opts [Hash] options hash, see #request
|
95
|
+
# @return [String] the request URL
|
96
|
+
# @private
|
97
|
+
def self.build_url(resource, opts = {})
|
98
|
+
base_url = VSTS.configuration.base_url.sub(%r{\/+$}, "")
|
99
|
+
api_version = opts[:api_version] || VSTS.configuration.api_version
|
100
|
+
collection = opts[:collection] || VSTS.configuration.collection
|
101
|
+
team_project = opts[:team_project] || VSTS.configuration.team_project
|
102
|
+
urlparams = opts[:urlparams] || {}
|
103
|
+
area = opts[:area] || VSTS.configuration.area
|
104
|
+
resource.sub!(%r{^\/+}, "")
|
105
|
+
|
106
|
+
base = [base_url, collection, team_project, "_apis", area, resource].compact.join("/")
|
107
|
+
urlparams["api-version"] ||= api_version
|
108
|
+
url_encoded_params = URI.encode_www_form(urlparams) # makes url params from Hash
|
109
|
+
|
110
|
+
base + "?" + url_encoded_params
|
111
|
+
end
|
112
|
+
|
113
|
+
# Calculate the Authorization header for the API based on the personal access token
|
114
|
+
#
|
115
|
+
# @return [String] the Authorization header value for Basic auth, ie. "Basic jrigf9404vvxsoi48t048fdj=="
|
116
|
+
# @private
|
117
|
+
def self.authz_header_value
|
118
|
+
"Basic " + Base64.strict_encode64(":" + VSTS.configuration.personal_access_token)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Build URL parameters hash from options hash (used internally)
|
122
|
+
#
|
123
|
+
# @param opts [Hash] options hash with Symbol keys to build url parameters from
|
124
|
+
# @param paramnames [Array<String>, Array<Symbol>, Array<Array<String, String>>, Array<Hash>]
|
125
|
+
# list of VSTS URL parameter names and VSTS prefixes to filter from opts
|
126
|
+
# @return [Hash] url parameters hash
|
127
|
+
# @private
|
128
|
+
def self.build_params(opts, paramnames)
|
129
|
+
urlparams = {}
|
130
|
+
paramnames.each do |paramname_with_prefix|
|
131
|
+
case paramname_with_prefix
|
132
|
+
when String, Symbol
|
133
|
+
prefix = ""
|
134
|
+
paramname = paramname_with_prefix
|
135
|
+
when Array
|
136
|
+
prefix = paramname_with_prefix[0]
|
137
|
+
paramname = paramname_with_prefix[1]
|
138
|
+
when Hash
|
139
|
+
prefix = paramname_with_prefix.first[0]
|
140
|
+
paramname = paramname_with_prefix.first[1]
|
141
|
+
else
|
142
|
+
VSTS.logger.warn("Invalid type in paramlist in APIClient##build_params: #{paramname_with_prefix.class}")
|
143
|
+
next
|
144
|
+
end
|
145
|
+
urlparams["#{prefix}#{paramname}"] = opts[paramname] if opts[paramname]
|
146
|
+
end
|
147
|
+
urlparams
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
# VSTS namespace
|
4
|
+
module VSTS
|
5
|
+
# VSTS API response
|
6
|
+
class APIResponse
|
7
|
+
attr_accessor :request, :code, :body, :parsed
|
8
|
+
|
9
|
+
# Constructor
|
10
|
+
#
|
11
|
+
# @param request [Hash] the hash that was passed to RestClient as the request descriptor
|
12
|
+
# @param response [RestClient::Response]
|
13
|
+
def initialize(request, response)
|
14
|
+
@request = request
|
15
|
+
@code = response.code
|
16
|
+
@body = response.body
|
17
|
+
begin
|
18
|
+
@parsed = JSON.parse(@body)
|
19
|
+
rescue JSON::ParserError
|
20
|
+
@parsed = nil
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|