gergich 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8bb44310656cc033c1dd8357edf4e31b1c821032
4
+ data.tar.gz: 3ca01cfdaf8f068468d2bc66c465ad735b74f4c5
5
+ SHA512:
6
+ metadata.gz: e1ed7d601c5be30d29bd8b554bac395faceece03ab683065764f9d5516ba7603e5525810419e2cc650fcf780a5d7b05d1d485441d8f9c405c70696073e141ef2
7
+ data.tar.gz: 425a93068b6a9732b9a15273c1dbdc351976de0db035222a35bf3dd64f1fa32183d3be5b072c8876d575984976b03fa4bc96906e3e85eb278b2cc419195d3bbf
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2015-2016 Instructure, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOa AND
17
+ NONINFRINGEMENT. IN NO EVENT SaALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,154 @@
1
+ # Gergich
2
+
3
+ Gergich is a command-line tool (and ruby lib) for easily posting comments
4
+ on a [Gerrit](https://www.gerritcodereview.com/) review from a CI
5
+ environment. It can be wired up to linters (rubocop, eslint, etc.) so that
6
+ you can get nice inline comments right on the Gerrit review. That way
7
+ developers don't have to go digging through CI logs to see why their
8
+ builds failed.
9
+
10
+ ## How does it work?
11
+
12
+ Gergich maintains a little sqlite db of any draft comments/labels/etc.
13
+ for the current patchset (defined by revision+ChangeId). This way
14
+ different processes can all contribute to the review. For example,
15
+ various linters add inline comments, and when the CI build finishes,
16
+ Gergich publishes the review to Gerrit.
17
+
18
+ ## Limitations
19
+
20
+ Because everything is synchronized/stored in a local sqlite db, you
21
+ should only call Gergich from a single box/build per patchset. Gergich
22
+ does a check when publishing to ensure he hasn't already posted on this
23
+ patchset before; if he has, publish will be a no-op. This protects
24
+ against reposts (say, on a retrigger), but it does mean that you shouldn't
25
+ have completely different builds posting Gergich comments on the same
26
+ revision, unless you set up different credentials for each.
27
+
28
+ ## Installation
29
+
30
+ Add the following to your Gemfile (perhaps in your `:test` group?):
31
+
32
+ ```ruby
33
+ gem "gergich"
34
+ ```
35
+
36
+ To use Gergich, you'll need a Gerrit user whose credentials it'll use
37
+ (ideally not your own). With your shiny new username and password in hand,
38
+ set `GERGICH_USER` and `GERGICH_KEY` accordingly in your CI environment.
39
+
40
+ Additionally, Gergich needs to know where your Gerrit installation
41
+ lives, so be sure to set `GERRIT_BASE_URL` (e.g.
42
+ `https://gerrit.example.com`) or `GERRIT_HOST` (e.g. `gerrit.example.com`).
43
+
44
+ ## Usage
45
+
46
+ Run `gergich help` for detailed information about all supported commands.
47
+ In your build scripts, you'll typically be using `gergich comment`,
48
+ `gergich capture` and `gergich publish`. Comments are stored locally in a
49
+ sqlite database until you publish. This way you can queue up comments from
50
+ many disparate processes. Comments are published to `HEAD`'s corresponding
51
+ patchset in Gerrit (based on Change-Id + `<sha>`)
52
+
53
+ ### `gergich comment <comment_data>`
54
+
55
+ `<comment_data>` is a JSON object (or array of objects). Each comment
56
+ object should have the following properties:
57
+
58
+ * **path** - the relative file path, e.g. "app/models/user.rb"
59
+ * **position** - either a number (line) or an object (range). If an object,
60
+ must have the following numeric properties:
61
+ * start_line
62
+ * start_character
63
+ * end_line
64
+ * end_character
65
+ * **message** - the text of the comment
66
+ * **severity** - `"info"|"warn"|"error"` - this will automatically prefix
67
+ the comment (e.g. `"[ERROR] message here"`), and the most severe comment
68
+ will be used to determine the overall `Code-Review` score (0, -1, or -2
69
+ respectively)
70
+
71
+ Note that a cover message and `Code-Review` score will be inferred from the
72
+ most severe comment.
73
+
74
+ #### Examples
75
+
76
+ ```bash
77
+ gergich comment '{"path":"foo.rb","position":3,"severity":"error",
78
+ "message":"ಠ_ಠ"}'
79
+ gergich comment '{"path":"bar.rb","severity":"warn",
80
+ "position":{"start_line":3,"start_character":5,...},
81
+ "message":"¯\_(ツ)_/¯"}'
82
+ gergich comment '[{"path":"baz.rb",...}, {...}, {...}]'
83
+ ```
84
+
85
+ ### `gergich capture <format> <command>`
86
+
87
+ For common linting formats, `gergich capture` can be used to automatically
88
+ do `gergich comment` calls so you don't have to wire it up yourself.
89
+
90
+ `<format>` - One of the following:
91
+
92
+ * `rubocop`
93
+ * `eslint`
94
+ * `i18nliner`
95
+ * `custom:<path>:<class_name>` - file path and ruby class_name of a custom
96
+ formatter.
97
+
98
+ `<command>` - The command to run whose output corresponds to `<format>`
99
+
100
+ #### Custom formatters:
101
+
102
+ To create a custom formatter, create a class that implements a `run`
103
+ method that takes a string of command output and returns an array of
104
+ comment hashes (see `gergich comment`'s `<comment_data>` format), e.g.
105
+
106
+ ```ruby
107
+ class MyFormatter
108
+ def run(output)
109
+ output.scan(/^Oh noes! (.+?):(\d+): (.*)$/).map do |file, line, error|
110
+ { path: file, message: error, position: line.to_i, severity: "error" }
111
+ end
112
+ end
113
+ end
114
+ ```
115
+
116
+ #### Examples:
117
+
118
+ ```bash
119
+ gergich capture rubocop "bundle exec rubocop"
120
+
121
+ gergich capture eslint eslint
122
+
123
+ gergich capture i18nliner "rake i18nliner:check"
124
+
125
+ gergich capture custom:./gergich/xss:Gergich::XSS "node script/xsslint"
126
+ ```
127
+
128
+ ### `gergich publish`
129
+
130
+ Publish all draft comments/labels/messages for this patchset. no-op if
131
+ there are none.
132
+
133
+ The cover message and `Code-Review` label (e.g. -2) are inferred from the
134
+ comments, but labels and messages may be manually set (via `gergich
135
+ message` and `gergich labels`)
136
+
137
+ ## How do I test my changes?
138
+
139
+ Write tests of course, but also be sure to test it end-to-end via the
140
+ CLI... Run `gergich` for a list of commands, as well as help for each
141
+ command. There's also a `citest` thing that we run on our Jenkins that
142
+ ensures each CLI command succeeds, but it doesn't test all branches for
143
+ each command.
144
+
145
+ After running a given command, you can run `gergich status` to see the
146
+ current draft of the review (what will be sent to Gerrit when you do
147
+ `gergich publish`).
148
+
149
+ You can even do a test `publish` to Gerrit, if you have valid Gerrit
150
+ credentials in `GERGICH_USER` / `GERGICH_KEY`. It infers the Gerrit patchset
151
+ from the working directory, which may or may not correspond to something
152
+ actually in Gerrit, so YMMV. That means you can post to a Gergich commit
153
+ in Gerrit, or if you run it from another project's directory, you can post
154
+ to its Gerrit revision.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "simplecov"
4
+
5
+ SimpleCov.command_name "check_coverage"
6
+ SimpleCov.minimum_coverage 90
7
+ SimpleCov.at_exit { SimpleCov.result.format! }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ if ENV["COVERAGE"]
4
+ require "simplecov"
5
+ SimpleCov.command_name "cli:#{ARGV.join(' ')}"
6
+ end
7
+
8
+ require_relative "../lib/gergich/cli/gergich"
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding=utf-8
3
+
4
+ require_relative "../lib/gergich/cli/master_bouncer"
@@ -0,0 +1,455 @@
1
+ require "sqlite3"
2
+ require "json"
3
+ require "fileutils"
4
+ require "httparty"
5
+
6
+ GERGICH_REVIEW_LABEL = ENV.fetch("GERGICH_REVIEW_LABEL", "Code-Review")
7
+ GERGICH_USER = ENV.fetch("GERGICH_USER", "gergich")
8
+ GERGICH_GIT_PATH = ENV.fetch("GERGICH_GIT_PATH", ".")
9
+
10
+ GergichError = Class.new(StandardError)
11
+
12
+ module Gergich
13
+ def self.git(args)
14
+ Dir.chdir(GERGICH_GIT_PATH) do
15
+ `git #{args} 2>/dev/null`
16
+ end
17
+ end
18
+
19
+ class Commit
20
+ attr_reader :ref
21
+
22
+ def initialize(ref = "HEAD", revision_number = nil)
23
+ @ref = ref
24
+ @revision_number = revision_number
25
+ end
26
+
27
+ def info
28
+ @info ||= begin
29
+ output = Gergich.git("log -1 #{ref}")
30
+ /\Acommit (?<revision_id>[0-9a-f]+).*^\s*Change-Id: (?<change_id>\w+)/m =~ output
31
+ { revision_id: revision_id, change_id: change_id }
32
+ end
33
+ end
34
+
35
+ def files
36
+ @files ||= Gergich.git("diff-tree --no-commit-id --name-only -r #{ref}").split
37
+ end
38
+
39
+ def revision_id
40
+ info[:revision_id]
41
+ end
42
+
43
+ def revision_number
44
+ @revision_number ||= begin
45
+ gerrit_info = API.get("/changes/?q=#{change_id}&o=ALL_REVISIONS")[0]
46
+ raise GergichError, "Gerrit patchset not found" unless gerrit_info
47
+ gerrit_info["revisions"][revision_id]["_number"]
48
+ end
49
+ end
50
+
51
+ def change_id
52
+ info[:change_id]
53
+ end
54
+ end
55
+
56
+ class Review
57
+ attr_reader :commit, :draft
58
+
59
+ def initialize(commit = Commit.new, draft = Draft.new)
60
+ @commit = commit
61
+ @draft = draft
62
+ end
63
+
64
+ # Public: publish all draft comments/labels/messages
65
+ def publish!(allow_repost = false)
66
+ # only publish if we have something to say or if our last score was negative
67
+ return unless anything_to_publish? || previous_score_negative?
68
+
69
+ # TODO: rather than just bailing, fetch the comments and only post
70
+ # ones that don't exist (if any)
71
+ return if already_commented? && !allow_repost
72
+
73
+ API.post(generate_url, generate_payload)
74
+
75
+ # because why not
76
+ if rand < 0.01 && GERGICH_USER == "gergich"
77
+ API.put("/accounts/self/name", { name: whats_his_face }.to_json)
78
+ end
79
+
80
+ review_info
81
+ end
82
+
83
+ def anything_to_publish?
84
+ !review_info[:comments].empty? ||
85
+ !review_info[:cover_message].empty? ||
86
+ review_info[:labels].any? { |_, score| score != 0 }
87
+ end
88
+
89
+ # Public: show the current draft for this patchset
90
+ def status
91
+ puts "Gergich DB: #{draft.db_file}"
92
+ unless anything_to_publish?
93
+ puts "Nothing to publish"
94
+ return
95
+ end
96
+
97
+ puts "ChangeId: #{commit.change_id}"
98
+ puts "Revision: #{commit.revision_id}"
99
+
100
+ puts
101
+ review_info[:labels].each do |name, score|
102
+ puts "#{name}: #{score}"
103
+ end
104
+
105
+ puts
106
+ puts "Cover Message:"
107
+ puts review_info[:cover_message]
108
+
109
+ unless review_info[:comments].empty?
110
+ puts
111
+ puts "Inline Comments:"
112
+ puts
113
+
114
+ review_info[:comments].each do |file, comments|
115
+ comments.each do |comment|
116
+ puts "#{file}:#{comment[:line] || comment[:range]['start_line']}\n#{comment[:message]}"
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def previous_score
123
+ last_message = my_messages
124
+ .sort_by { |message| message["date"] }
125
+ .last
126
+
127
+ text = last_message && last_message["message"] || ""
128
+ text =~ /^-[12]/
129
+
130
+ ($& || "").to_i
131
+ end
132
+
133
+ def previous_score_negative?
134
+ previous_score < 0
135
+ end
136
+
137
+ def already_commented?
138
+ revision_number = commit.revision_number
139
+ my_messages.any? { |message| message["_revision_number"] == revision_number }
140
+ end
141
+
142
+ def my_messages
143
+ @messages ||= API.get("/changes/#{commit.change_id}/detail")["messages"]
144
+ .select { |message| message["author"] && message["author"]["username"] == GERGICH_USER }
145
+ end
146
+
147
+ def whats_his_face
148
+ "#{%w[Garry Larry Terry Jerry].sample} Gergich (Bot)"
149
+ end
150
+
151
+ def review_info
152
+ @review_info ||= draft.info
153
+ end
154
+
155
+ def generate_url
156
+ "/changes/#{commit.change_id}/revisions/#{commit.revision_id}/review"
157
+ end
158
+
159
+ def generate_payload
160
+ {
161
+ message: review_info[:cover_message],
162
+ labels: review_info[:labels],
163
+ comments: review_info[:comments],
164
+ # we don't want the post to fail if another
165
+ # patchset was created in the interim
166
+ strict_labels: false
167
+ }.to_json
168
+ end
169
+ end
170
+
171
+ class API
172
+ class << self
173
+ def get(url)
174
+ perform(:get, url)
175
+ end
176
+
177
+ def post(url, body)
178
+ perform(:post, url, body)
179
+ end
180
+
181
+ def put(url, body)
182
+ perform(:put, url, body)
183
+ end
184
+
185
+ private
186
+
187
+ def perform(method, url, body = nil)
188
+ options = base_options
189
+ if body
190
+ options[:headers] = { "Content-Type" => "application/json" }
191
+ options[:body] = body
192
+ end
193
+ ret = HTTParty.send(method, url, options).body
194
+ if ret.sub!(/\A\)\]\}'\n/, "") && ret =~ /\A("|\[|\{)/
195
+ JSON.parse(ret)
196
+ else
197
+ raise("Non-JSON response: #{ret}")
198
+ end
199
+ end
200
+
201
+ def base_uri
202
+ @base_url ||= \
203
+ ENV["GERRIT_BASE_URL"] ||
204
+ ENV.key?("GERRIT_HOST") && "https://#{ENV['GERRIT_HOST']}" ||
205
+ raise("need to set GERRIT_BASE_URL or GERRIT_HOST")
206
+ end
207
+
208
+ def base_options
209
+ {
210
+ base_uri: base_uri + "/a",
211
+ digest_auth: {
212
+ username: GERGICH_USER,
213
+ password: ENV.fetch("GERGICH_KEY")
214
+ }
215
+ }
216
+ end
217
+ end
218
+ end
219
+
220
+ class Draft
221
+ SEVERITY_MAP = {
222
+ "info" => 0,
223
+ "warn" => -1,
224
+ "error" => -2
225
+ }.freeze
226
+
227
+ attr_reader :db, :commit
228
+
229
+ def initialize(commit = Commit.new)
230
+ @commit = commit
231
+ end
232
+
233
+ def db_file
234
+ @db_file ||= File.expand_path("/tmp/#{GERGICH_USER}-#{commit.revision_id}.sqlite3")
235
+ end
236
+
237
+ def db
238
+ @db ||= begin
239
+ db_exists = File.exist?(db_file)
240
+ db = SQLite3::Database.new(db_file)
241
+ db.results_as_hash = true
242
+ create_db_schema! unless db_exists
243
+ db
244
+ end
245
+ end
246
+
247
+ def reset!
248
+ FileUtils.rm_f(db_file)
249
+ end
250
+
251
+ def create_db_schema!
252
+ db.execute <<-SQL
253
+ CREATE TABLE comments (
254
+ path VARCHAR,
255
+ position VARCHAR,
256
+ message VARCHAR,
257
+ severity VARCHAR
258
+ );
259
+ SQL
260
+ db.execute <<-SQL
261
+ CREATE TABLE labels (
262
+ name VARCHAR,
263
+ score INTEGER
264
+ );
265
+ SQL
266
+ db.execute <<-SQL
267
+ CREATE TABLE messages (
268
+ message VARCHAR
269
+ );
270
+ SQL
271
+ end
272
+
273
+ # Public: add a label to the draft
274
+ #
275
+ # name - the label name, e.g. "Code-Review"
276
+ # score - the score, e.g. "-1"
277
+ #
278
+ # You can set add the same label multiple times, but the lowest score
279
+ # for a given label will be used. This also applies to the inferred
280
+ # "Code-Review" score from comments; if it is non-zero, it will trump
281
+ # a higher score set here.
282
+ def add_label(name, score)
283
+ score = score.to_i
284
+ raise "invalid score" if score < -2 || score > 1
285
+ raise "can't set #{name}" if %w[Verified].include?(name)
286
+
287
+ db.execute "INSERT INTO labels (name, score) VALUES (?, ?)",
288
+ [name, score]
289
+ end
290
+
291
+ # Public: add something to the cover message
292
+ #
293
+ # These messages will appear after the "-1" (or whatever)
294
+ def add_message(message)
295
+ db.execute "INSERT INTO messages (message) VALUES (?)", [message]
296
+ end
297
+
298
+ #
299
+ # Public: add an inline comment to the draft
300
+ #
301
+ # path - the relative file path, e.g. "app/models/user.rb"
302
+ # position - either a Fixnum (line number) or a Hash (range). If a
303
+ # Hash, must have the following Fixnum properties:
304
+ # * start_line
305
+ # * start_character
306
+ # * end_line
307
+ # * end_character
308
+ # message - the text of the comment
309
+ # severity - "info"|"warn"|"error" - this will automatically prefix
310
+ # the comment (e.g. "[ERROR] message here"), and the most
311
+ # severe comment will be used to determine the overall
312
+ # Code-Review score (0, -1, or -2 respectively)
313
+ def add_comment(path, position, message, severity)
314
+ raise "invalid position `#{position}`" unless valid_position?(position)
315
+ position = position.to_json if position.is_a?(Hash)
316
+ raise "invalid severity `#{severity}`" unless SEVERITY_MAP.key?(severity)
317
+ raise "no message specified" unless message.is_a?(String) && !message.empty?
318
+
319
+ db.execute "INSERT INTO comments (path, position, message, severity) VALUES (?, ?, ?, ?)",
320
+ [path, position, message, severity]
321
+ end
322
+
323
+ POSITION_KEYS = %w[end_character end_line start_character start_line].freeze
324
+ def valid_position?(position)
325
+ (
326
+ position.is_a?(Fixnum) && position >= 0
327
+ ) || (
328
+ position.is_a?(Hash) && position.keys.sort == POSITION_KEYS &&
329
+ position.values.all? { |v| v.is_a?(Fixnum) && v >= 0 }
330
+ )
331
+ end
332
+
333
+ def labels
334
+ @labels ||= begin
335
+ labels = { GERGICH_REVIEW_LABEL => 0 }
336
+ db.execute("SELECT name, MIN(score) AS score FROM labels GROUP BY name").each do |row|
337
+ labels[row["name"]] = row["score"]
338
+ end
339
+ score = min_comment_score
340
+ labels[GERGICH_REVIEW_LABEL] = score if score < 0 && score < labels[GERGICH_REVIEW_LABEL]
341
+ labels
342
+ end
343
+ end
344
+
345
+ def all_comments
346
+ @all_comments ||= begin
347
+ comments = {}
348
+
349
+ sql = "SELECT path, position, message, severity FROM comments"
350
+ db.execute(sql).each do |row|
351
+ inline = changed_files.include?(row["path"])
352
+ comments[row["path"]] ||= FileReview.new(row["path"], inline)
353
+ comments[row["path"]].add_comment(row["position"],
354
+ row["message"],
355
+ row["severity"])
356
+ end
357
+
358
+ comments.values
359
+ end
360
+ end
361
+
362
+ def inline_comments
363
+ all_comments.select(&:inline)
364
+ end
365
+
366
+ def other_comments
367
+ all_comments.reject(&:inline)
368
+ end
369
+
370
+ def min_comment_score
371
+ all_comments.inject(0) { |a, e| [a, e.min_score].min }
372
+ end
373
+
374
+ def changed_files
375
+ @changed_files ||= commit.files + ["/COMMIT_MSG"]
376
+ end
377
+
378
+ def info
379
+ @info ||= begin
380
+ comments = Hash[inline_comments.map { |file| [file.path, file.to_a] }]
381
+
382
+ {
383
+ comments: comments,
384
+ cover_message: cover_message,
385
+ total_comments: all_comments.map(&:count).inject(&:+),
386
+ score: labels[GERGICH_REVIEW_LABEL],
387
+ labels: labels
388
+ }
389
+ end
390
+ end
391
+
392
+ def messages
393
+ db.execute("SELECT message FROM messages").map { |row| row["message"] }
394
+ end
395
+
396
+ def orphaned_message
397
+ message = "NOTE: I couldn't create inline comments for everything. " \
398
+ "Although this isn't technically part of your commit, you " \
399
+ "should still check it out (i.e. side effects or auto-" \
400
+ "generated from stuff you *did* change):"
401
+
402
+ other_comments.each do |file|
403
+ file.comments.each do |position, comments|
404
+ comments.each do |comment|
405
+ line = position.is_a?(Fixnum) ? position : position["start_line"]
406
+ message << "\n\n#{file.path}:#{line}: #{comment}"
407
+ end
408
+ end
409
+ end
410
+
411
+ message
412
+ end
413
+
414
+ def cover_message
415
+ score = labels[GERGICH_REVIEW_LABEL]
416
+ parts = messages
417
+ parts.unshift score.to_s if score < 0
418
+
419
+ parts << orphaned_message unless other_comments.empty?
420
+ parts.join("\n\n")
421
+ end
422
+ end
423
+
424
+ class FileReview
425
+ attr_accessor :path, :comments, :inline, :min_score
426
+
427
+ def initialize(path, inline)
428
+ self.path = path
429
+ self.comments = Hash.new { |hash, position| hash[position] = [] }
430
+ self.inline = inline
431
+ end
432
+
433
+ def add_comment(position, message, severity)
434
+ position = position.to_i if position =~ /\A\d+\z/
435
+ comments[position] << "[#{severity.upcase}] #{message}"
436
+ self.min_score = [min_score || 0, Draft::SEVERITY_MAP[severity]].min
437
+ end
438
+
439
+ def count
440
+ comments.size
441
+ end
442
+
443
+ def to_a
444
+ comments.map do |position, position_comments|
445
+ comment = position_comments.join("\n\n")
446
+ position_key = position.is_a?(Fixnum) ? :line : :range
447
+ position = JSON.parse(position) unless position.is_a?(Fixnum)
448
+ {
449
+ :message => comment,
450
+ position_key => position
451
+ }
452
+ end
453
+ end
454
+ end
455
+ end