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.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +154 -0
- data/bin/check_coverage +7 -0
- data/bin/gergich +8 -0
- data/bin/master_bouncer +4 -0
- data/lib/gergich.rb +455 -0
- data/lib/gergich/capture.rb +77 -0
- data/lib/gergich/capture/eslint_capture.rb +20 -0
- data/lib/gergich/capture/i18nliner_capture.rb +22 -0
- data/lib/gergich/capture/rubocop_capture.rb +19 -0
- data/lib/gergich/cli.rb +82 -0
- data/lib/gergich/cli/gergich.rb +247 -0
- data/lib/gergich/cli/master_bouncer.rb +109 -0
- data/spec/gergich/capture_spec.rb +59 -0
- data/spec/gergich_spec.rb +133 -0
- data/spec/spec_helper.rb +89 -0
- metadata +89 -0
checksums.yaml
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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.
|
data/bin/check_coverage
ADDED
data/bin/gergich
ADDED
data/bin/master_bouncer
ADDED
data/lib/gergich.rb
ADDED
@@ -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
|