gergich 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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