jiraby 0.0.1
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 +9 -0
- data/.rspec +3 -0
- data/.travis.yml +4 -0
- data/.yardopts +8 -0
- data/Gemfile +7 -0
- data/README.md +132 -0
- data/Rakefile +5 -0
- data/docs/development.md +20 -0
- data/docs/history.md +5 -0
- data/docs/ideas.md +54 -0
- data/docs/index.md +11 -0
- data/docs/usage.md +64 -0
- data/jiraby.gemspec +31 -0
- data/lib/jiraby.rb +8 -0
- data/lib/jiraby/entity.rb +21 -0
- data/lib/jiraby/exceptions.rb +8 -0
- data/lib/jiraby/issue.rb +109 -0
- data/lib/jiraby/jira.rb +319 -0
- data/lib/jiraby/json_resource.rb +136 -0
- data/lib/jiraby/project.rb +19 -0
- data/spec/data/field.json +32 -0
- data/spec/data/issue_10002.json +187 -0
- data/spec/data/issue_createmeta.json +35 -0
- data/spec/data/jira_issues.rb +265 -0
- data/spec/data/jira_projects.rb +117 -0
- data/spec/data/project_TST.json +97 -0
- data/spec/data/search_results.json +26 -0
- data/spec/entity_spec.rb +20 -0
- data/spec/issue_spec.rb +289 -0
- data/spec/jira_spec.rb +314 -0
- data/spec/json_resource_spec.rb +222 -0
- data/spec/mockapp/config.ru +6 -0
- data/spec/mockapp/index.html +10 -0
- data/spec/mockapp/jira.rb +61 -0
- data/spec/mockapp/views/auth/login_failed.erb +1 -0
- data/spec/mockapp/views/auth/login_success.erb +7 -0
- data/spec/mockapp/views/error.erb +3 -0
- data/spec/mockapp/views/field.erb +32 -0
- data/spec/mockapp/views/issue/TST-1.erb +186 -0
- data/spec/mockapp/views/issue/createmeta.erb +35 -0
- data/spec/mockapp/views/issue/err_nonexistent.erb +1 -0
- data/spec/mockapp/views/project/TST.erb +97 -0
- data/spec/mockapp/views/project/err_nonexistent.erb +4 -0
- data/spec/mockapp/views/search.erb +26 -0
- data/spec/project_spec.rb +20 -0
- data/spec/spec_helper.rb +26 -0
- data/tasks/mockjira.rake +10 -0
- data/tasks/pry.rake +28 -0
- data/tasks/spec.rake +9 -0
- data/tasks/test.rake +8 -0
- metadata +288 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/.yardopts
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
Jiraby
|
2
|
+
======
|
3
|
+
|
4
|
+
Jiraby is a Ruby wrapper for the [JIRA](http://www.atlassian.com/JIRA)
|
5
|
+
[REST API](https://docs.atlassian.com/jira/REST/latest/), supporting Jira
|
6
|
+
versions 6.2 and up.
|
7
|
+
|
8
|
+
[Full documentation is on rdoc.info](http://rubydoc.info/github/a-e/jiraby/master/frames).
|
9
|
+
|
10
|
+
|
11
|
+
Connect to Jira
|
12
|
+
---------------
|
13
|
+
|
14
|
+
Assuming your JIRA site is at `http://jira.enterprise.com`, and you have
|
15
|
+
an account `picard` with password `earlgrey`, you can connect like so:
|
16
|
+
|
17
|
+
require 'jiraby'
|
18
|
+
|
19
|
+
host = 'jira.enterprise.com:8080' # :PORT is optional
|
20
|
+
username = 'picard'
|
21
|
+
password = 'earlgrey'
|
22
|
+
|
23
|
+
jira = Jiraby::Jira.new(host, username, password)
|
24
|
+
|
25
|
+
[HTTP basic](http://en.wikipedia.org/wiki/Basic_access_authentication)
|
26
|
+
authentication is used for all requests.
|
27
|
+
|
28
|
+
|
29
|
+
REST API
|
30
|
+
--------
|
31
|
+
|
32
|
+
Methods in the [JIRA REST API](https://docs.atlassian.com/jira/REST/6.2/) can be
|
33
|
+
accessed directly using the `#get`, `#put`, `#post`, and `#delete` methods:
|
34
|
+
|
35
|
+
jira.get 'serverInfo' # info about Jira server
|
36
|
+
jira.get 'issue/TEST-1' # full details of TEST-1 issue
|
37
|
+
jira.get 'field' # all fields, both System and Custom
|
38
|
+
jira.get 'user/search', :username => 'bob' # all users matching "bob"
|
39
|
+
jira.get 'user/search?username=bob' # all users matching "bob"
|
40
|
+
|
41
|
+
jira.put 'issue/TEST-1', :fields => { # set one or more fields
|
42
|
+
:summary => "Modified summary",
|
43
|
+
:description => "New description"
|
44
|
+
}
|
45
|
+
|
46
|
+
jira.delete 'issue/TEST-1' # delete issue TEST-1
|
47
|
+
|
48
|
+
All REST methods return a `Jiraby::Entity` (a hash-like object built directly from
|
49
|
+
the JSON response), or an `Array` of them (for those REST methods that return arrays).
|
50
|
+
|
51
|
+
|
52
|
+
Wrappers
|
53
|
+
--------
|
54
|
+
|
55
|
+
You can look up a Jira issue using the `#issue` method:
|
56
|
+
|
57
|
+
issue = jira.issue('myproj-15')
|
58
|
+
issue = jira.issue('MYPROJ-15') # case-insensitive
|
59
|
+
|
60
|
+
issue.class
|
61
|
+
# => Jiraby::Issue
|
62
|
+
|
63
|
+
If you're interested, view the raw data returned from Jira:
|
64
|
+
|
65
|
+
issue.data
|
66
|
+
# => {
|
67
|
+
# 'id' => '10024',
|
68
|
+
# 'key' => 'MYPROJ-15',
|
69
|
+
# 'self' => 'http://jira.enterprise.com:8080/rest/api/2/issue/10024',
|
70
|
+
# 'fields' => {
|
71
|
+
# 'summary' => 'Realign the dilithium stabilizer matrix.',
|
72
|
+
# ...
|
73
|
+
# }
|
74
|
+
# }
|
75
|
+
|
76
|
+
Or use the higher-level methods provided by the `Issue` class:
|
77
|
+
|
78
|
+
issue['foo'] # Value of field 'foo'; same as `issue.data.fields.foo`
|
79
|
+
issue['foo'] = "Newval" # Assign to field 'foo'
|
80
|
+
issue.subtasks # Array of issue keys for this issue's subtasks
|
81
|
+
issue.is_subtask? # True if issue is a sub-task of another issue
|
82
|
+
issue.parent # For subtasks, issue key of parent issue
|
83
|
+
issue.is_assigned? # True if issue is assigned
|
84
|
+
|
85
|
+
When modifying fields, the changes will appear in the `Issue` instance immediately:
|
86
|
+
|
87
|
+
issue['summary'] = "Modified summary"
|
88
|
+
|
89
|
+
issue['summary']
|
90
|
+
# => "Modified summary"
|
91
|
+
|
92
|
+
But these changes are not saved back to Jira until you call `#save!`. Before
|
93
|
+
saving, you can check for pending changes:
|
94
|
+
|
95
|
+
issue.pending_changes?
|
96
|
+
# => true
|
97
|
+
|
98
|
+
issue.pending_changes
|
99
|
+
# => {"summary" => "Modified summary"}
|
100
|
+
|
101
|
+
Then save the updates back to Jira:
|
102
|
+
|
103
|
+
issue.save!
|
104
|
+
# => true
|
105
|
+
|
106
|
+
|
107
|
+
Copyright
|
108
|
+
---------
|
109
|
+
|
110
|
+
The MIT License
|
111
|
+
|
112
|
+
Copyright (c) 2014 Eric Pierce
|
113
|
+
|
114
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
115
|
+
a copy of this software and associated documentation files (the
|
116
|
+
"Software"), to deal in the Software without restriction, including
|
117
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
118
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
119
|
+
permit persons to whom the Software is furnished to do so, subject to
|
120
|
+
the following conditions:
|
121
|
+
|
122
|
+
The above copyright notice and this permission notice shall be
|
123
|
+
included in all copies or substantial portions of the Software.
|
124
|
+
|
125
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
126
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
127
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
128
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
129
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
130
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
131
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
132
|
+
|
data/Rakefile
ADDED
data/docs/development.md
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Development
|
2
|
+
===========
|
3
|
+
|
4
|
+
Testing
|
5
|
+
-------
|
6
|
+
|
7
|
+
Jiraby is tested using a locally-installed instance of Jira. For licensing and
|
8
|
+
size reasons that should be obvious, this local instance is not included in the
|
9
|
+
Jiraby codebase itself. Here are the assumptions that the Jiraby test suite
|
10
|
+
makes about your local Jira instance:
|
11
|
+
|
12
|
+
- It's running at http://localhost:8080/
|
13
|
+
- There is an administrator (in the 'jira-administrators' group):
|
14
|
+
- Username: admin
|
15
|
+
- Password: admin
|
16
|
+
- There is a regular user (in the 'jira-users' group):
|
17
|
+
- Username: user
|
18
|
+
- Password: user
|
19
|
+
- There is a project called 'TST'
|
20
|
+
|
data/docs/ideas.md
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
Jiraby API ideas
|
2
|
+
================
|
3
|
+
|
4
|
+
- Implement an attribute wrapper for hashes, to allow stuff like:
|
5
|
+
issue.project.key
|
6
|
+
issue.project.owner
|
7
|
+
(See http://pullmonkey.com/2008/01/06/convert-a-ruby-hash-into-a-class-object/)
|
8
|
+
|
9
|
+
Issue fields have different types. For example:
|
10
|
+
|
11
|
+
> iss.json['fields']['comment']
|
12
|
+
=> {"comments"=>[], "startAt"=>0, "total"=>0, "maxResults"=>0}
|
13
|
+
|
14
|
+
> iss.json['fields']['environment']
|
15
|
+
=> nil
|
16
|
+
|
17
|
+
> iss.json['fields']['summary']
|
18
|
+
=> "It's broken"
|
19
|
+
|
20
|
+
> iss.json['fields']['subtasks']
|
21
|
+
=> []
|
22
|
+
|
23
|
+
Further, these are different in different versions of Jira. The above are from
|
24
|
+
Jira 5.0, while Jira 4.4 produces:
|
25
|
+
|
26
|
+
> iss.json['fields']['comment']
|
27
|
+
=> {"name"=>"comment", "value"=>[], "type"=>"com.atlassian.jira.issue.fields.CommentSystemField"}
|
28
|
+
|
29
|
+
> iss.json['fields']['environment']
|
30
|
+
=> {"name"=>"environment", "type"=>"java.lang.String"}
|
31
|
+
|
32
|
+
> iss.json['fields']['summary']
|
33
|
+
=> {"name"=>"summary", "value"=>"Cherwell incident #33170", "type"=>"java.lang.String"}
|
34
|
+
|
35
|
+
> iss.json['fields']['sub-tasks'] # NOTE: Different key
|
36
|
+
=> {"name"=>"sub-tasks", "value"=>[], "type"=>"issuelinks"}
|
37
|
+
|
38
|
+
Maintaining compatibility between both of these could be a royal pain. It's
|
39
|
+
probably best to separate the REST / JSON stuff from the specific Jira/API
|
40
|
+
version we're using.
|
41
|
+
|
42
|
+
Consistency
|
43
|
+
-----------
|
44
|
+
|
45
|
+
The JIRA REST API is consistent in some things:
|
46
|
+
|
47
|
+
- It (almost) always returns JSON
|
48
|
+
Note: At least one method (PUT /issue/<key>) returns an empty string on success
|
49
|
+
- Use of `self` for each entity
|
50
|
+
|
51
|
+
Challenges:
|
52
|
+
|
53
|
+
- How to remain authenticated (pass JSONResource around to each REST request)?
|
54
|
+
|
data/docs/index.md
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
Jiraby
|
2
|
+
======
|
3
|
+
|
4
|
+
Jiraby is a Ruby interface to the [JIRA](http://www.atlassian.com/JIRA)
|
5
|
+
[REST API](http://docs.atlassian.com/jira/REST/latest/).
|
6
|
+
|
7
|
+
- [Usage](usage.md)
|
8
|
+
- [Development](development.md)
|
9
|
+
- [Ideas](ideas.md)
|
10
|
+
- [History](history.md)
|
11
|
+
|
data/docs/usage.md
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
Usage
|
2
|
+
=====
|
3
|
+
|
4
|
+
Assuming your JIRA site is at `http://jira.enterprise.com`, and you have
|
5
|
+
an account `picard` with password `earlgrey`, you can connect like so:
|
6
|
+
|
7
|
+
require 'jiraby'
|
8
|
+
|
9
|
+
jira = Jiraby::Jira.new('http://jira.enterprise.com')
|
10
|
+
jira.login('picard', 'earlgrey')
|
11
|
+
|
12
|
+
Using Jiraby from the Ruby console:
|
13
|
+
|
14
|
+
$ bundle console
|
15
|
+
> require 'lib/jiraby/jira'
|
16
|
+
> jira = Jiraby::Jira.new('http://localhost:8080')
|
17
|
+
> jira.login('username', 'password')
|
18
|
+
> jira.logout
|
19
|
+
|
20
|
+
Methods in the [JIRA REST API](https://docs.atlassian.com/jira/REST/6.2/) can be
|
21
|
+
accessed like so:
|
22
|
+
|
23
|
+
> jira.get('issue/TEST-1')
|
24
|
+
=> {"id"=>"10000",
|
25
|
+
"self"=>"http://localhost:8080/rest/api/2/issue/10000",
|
26
|
+
"key"=>"TEST-1",
|
27
|
+
"fields"=>{ ... }
|
28
|
+
}
|
29
|
+
|
30
|
+
> jira.get('serverInfo')
|
31
|
+
=> {"baseUrl"=>"http://localhost:8080",
|
32
|
+
"version"=>"6.2",
|
33
|
+
"versionNumbers"=>[6, 2, 0],
|
34
|
+
"buildNumber"=>6252,
|
35
|
+
"buildDate"=>"2014-02-19T00:00:00.000-0700",
|
36
|
+
"serverTime"=>"2014-03-06T08:27:04.116-0700",
|
37
|
+
"scmInfo"=>"aa343257d4ce030d9cb8c531be520be9fac1c996",
|
38
|
+
"serverTitle"=>"Jiraby Test"}
|
39
|
+
|
40
|
+
> jira.get('resolution/1')
|
41
|
+
=> {"self"=>"http://localhost:8080/rest/api/2/resolution/1",
|
42
|
+
"id"=>"1",
|
43
|
+
"description"=>"A fix for this issue is checked into the tree and tested.",
|
44
|
+
"name"=>"Fixed"}
|
45
|
+
|
46
|
+
Passing parameters to GET:
|
47
|
+
|
48
|
+
> jira.get('user/search?username=admin')
|
49
|
+
=> [{"self"=>"http://localhost:8080/rest/api/2/user?username=admin",
|
50
|
+
"key"=>"admin",
|
51
|
+
"name"=>"admin",
|
52
|
+
"emailAddress"=>"epierce@automation-excellence.com",
|
53
|
+
"avatarUrls"=>
|
54
|
+
{"16x16"=>
|
55
|
+
"http://localhost:8080/secure/useravatar?size=xsmall&avatarId=10122",
|
56
|
+
"24x24"=>
|
57
|
+
"http://localhost:8080/secure/useravatar?size=small&avatarId=10122",
|
58
|
+
"32x32"=>
|
59
|
+
"http://localhost:8080/secure/useravatar?size=medium&avatarId=10122",
|
60
|
+
"48x48"=>"http://localhost:8080/secure/useravatar?avatarId=10122"},
|
61
|
+
"displayName"=>"Admin Istrator",
|
62
|
+
"active"=>true,
|
63
|
+
"timeZone"=>"America/Denver"}]
|
64
|
+
|
data/jiraby.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "jiraby"
|
3
|
+
s.version = "0.0.1"
|
4
|
+
s.summary = "Jira-Ruby bridge"
|
5
|
+
s.description = <<-EOS
|
6
|
+
Jiraby is a Ruby wrapper for the JIRA REST API,
|
7
|
+
supporting Jira 6.x.
|
8
|
+
EOS
|
9
|
+
s.authors = ["Eric Pierce"]
|
10
|
+
s.email = "wapcaplet88@gmail.com"
|
11
|
+
s.homepage = "http://github.com/a-e/jiraby"
|
12
|
+
s.platform = Gem::Platform::RUBY
|
13
|
+
|
14
|
+
s.add_dependency 'rest-client'
|
15
|
+
s.add_dependency 'yajl-ruby'
|
16
|
+
s.add_dependency 'hashie'
|
17
|
+
|
18
|
+
s.add_development_dependency 'rake'
|
19
|
+
s.add_development_dependency 'rspec'
|
20
|
+
s.add_development_dependency 'simplecov'
|
21
|
+
s.add_development_dependency 'pry'
|
22
|
+
s.add_development_dependency 'sinatra'
|
23
|
+
s.add_development_dependency 'rakeup'
|
24
|
+
s.add_development_dependency 'thin'
|
25
|
+
s.add_development_dependency 'yard'
|
26
|
+
s.add_development_dependency 'redcarpet'
|
27
|
+
|
28
|
+
s.files = `git ls-files`.split("\n")
|
29
|
+
s.require_path = 'lib'
|
30
|
+
end
|
31
|
+
|
data/lib/jiraby.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'hashie'
|
2
|
+
|
3
|
+
module Jiraby
|
4
|
+
# Represents some data structure in Jira, as it would be returned by
|
5
|
+
# a REST API method.
|
6
|
+
class Entity < Hashie::Mash
|
7
|
+
# If no `args` are given, return the value in the `key` field (often used
|
8
|
+
# as an identifier in Jira). If `args` are given, pass-through to Hash's
|
9
|
+
# regular `key` method (returning the key for a given value).
|
10
|
+
def key(*args)
|
11
|
+
# Pass-through to Hash's `key` method
|
12
|
+
if args.length > 0
|
13
|
+
super(*args)
|
14
|
+
else
|
15
|
+
return self['key']
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
end # class Entity
|
20
|
+
end # module Jiraby
|
21
|
+
|
data/lib/jiraby/issue.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'jiraby/exceptions'
|
2
|
+
require 'jiraby/entity'
|
3
|
+
|
4
|
+
module Jiraby
|
5
|
+
class Issue
|
6
|
+
def initialize(jira_instance, json_data={})
|
7
|
+
@jira = jira_instance
|
8
|
+
@data = Entity.new(json_data)
|
9
|
+
# Modifications are stored here until #save is called
|
10
|
+
@pending_changes = Entity.new
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :jira, :data, :pending_changes
|
14
|
+
|
15
|
+
# Return this issue's `key`
|
16
|
+
def key
|
17
|
+
return @data.key
|
18
|
+
end
|
19
|
+
|
20
|
+
# Set field `name_or_id` equal to `value`.
|
21
|
+
def []=(name_or_id, value)
|
22
|
+
@pending_changes[self.field_id(name_or_id)] = value
|
23
|
+
end
|
24
|
+
|
25
|
+
# Return the value in field `name_or_id`.
|
26
|
+
def [](name_or_id)
|
27
|
+
_id = self.field_id(name_or_id)
|
28
|
+
return @pending_changes[_id] || @data.fields[_id]
|
29
|
+
end
|
30
|
+
|
31
|
+
# Return a field ID, given a name or ID. `name_or_id` may be the field's ID
|
32
|
+
# (like "subtasks" or "customfield_10001"), or it may be the human-readable
|
33
|
+
# field name (like "Sub-Tasks" or "My Custom Field").
|
34
|
+
#
|
35
|
+
# TODO: Raise an exception on invalid name_or_id?
|
36
|
+
def field_id(name_or_id)
|
37
|
+
if @data.fields.include?(name_or_id)
|
38
|
+
return name_or_id
|
39
|
+
else
|
40
|
+
_id = @jira.field_mapping.key(name_or_id)
|
41
|
+
if _id.nil?
|
42
|
+
raise InvalidField.new("Invalid field name or ID: #{name_or_id}")
|
43
|
+
end
|
44
|
+
return _id
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Return true if this issue has a field with the given name or ID,
|
49
|
+
# false otherwise.
|
50
|
+
def has_field?(name_or_id)
|
51
|
+
begin
|
52
|
+
self.field_id(name_or_id)
|
53
|
+
rescue InvalidField
|
54
|
+
return false
|
55
|
+
else
|
56
|
+
return true
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Return true if this issue is a subtask, false otherwise.
|
61
|
+
def is_subtask?
|
62
|
+
return @data.fields.issuetype.subtask
|
63
|
+
end
|
64
|
+
|
65
|
+
# Return true if this issue is assigned to someone, false otherwise.
|
66
|
+
def is_assigned?
|
67
|
+
return !@data.fields.assignee.nil?
|
68
|
+
end
|
69
|
+
|
70
|
+
# Return this issue's parent key, or nil if this issue has no parent.
|
71
|
+
def parent
|
72
|
+
if is_subtask?
|
73
|
+
return @data.fields.parent.key
|
74
|
+
else
|
75
|
+
return nil
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Return this issue's subtask keys
|
80
|
+
def subtasks
|
81
|
+
return @data.fields.subtasks.collect { |st| st.key }
|
82
|
+
end
|
83
|
+
|
84
|
+
# Return a sorted list of valid field IDs for this issue.
|
85
|
+
def field_ids
|
86
|
+
return @data.fields.keys.sort
|
87
|
+
end
|
88
|
+
|
89
|
+
def editmeta
|
90
|
+
return @jira.get("issue/#{@data.key}/editmeta")
|
91
|
+
end
|
92
|
+
|
93
|
+
# Return true if this issue has been modified since saving.
|
94
|
+
def pending_changes?
|
95
|
+
return !@pending_changes.empty?
|
96
|
+
end
|
97
|
+
|
98
|
+
# Save this issue by sending a PUT request.
|
99
|
+
# Return true if save was successful.
|
100
|
+
def save!
|
101
|
+
json_data = {'fields' => @pending_changes}
|
102
|
+
# TODO: Handle failed save
|
103
|
+
@jira.put("issue/#{@data.key}", json_data)
|
104
|
+
@data.fields.merge!(@pending_changes)
|
105
|
+
@pending_changes = Entity.new
|
106
|
+
return true
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|