reviewkit 0.1.0
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/CHANGELOG.md +23 -0
- data/CODE_OF_CONDUCT.md +123 -0
- data/CONTRIBUTING.md +44 -0
- data/MIT-LICENSE +20 -0
- data/README.md +335 -0
- data/Rakefile +7 -0
- data/SECURITY.md +18 -0
- data/app/assets/builds/reviewkit/application.css +2 -0
- data/app/assets/javascripts/reviewkit/application.js +12 -0
- data/app/assets/javascripts/reviewkit/controllers/file_nav_controller.js +24 -0
- data/app/assets/javascripts/reviewkit/controllers/review_index_controller.js +84 -0
- data/app/assets/tailwind/reviewkit/application.css +865 -0
- data/app/controllers/reviewkit/application_controller.rb +80 -0
- data/app/controllers/reviewkit/comments_controller.rb +147 -0
- data/app/controllers/reviewkit/review_threads_controller.rb +277 -0
- data/app/controllers/reviewkit/reviews_controller.rb +142 -0
- data/app/helpers/reviewkit/application_helper.rb +12 -0
- data/app/helpers/reviewkit/asset_helper.rb +39 -0
- data/app/helpers/reviewkit/diff_helper.rb +230 -0
- data/app/helpers/reviewkit/flash_helper.rb +36 -0
- data/app/helpers/reviewkit/frame_helper.rb +37 -0
- data/app/helpers/reviewkit/icon_helper.rb +107 -0
- data/app/helpers/reviewkit/review_thread_helper.rb +54 -0
- data/app/models/concerns/reviewkit/notifies_lifecycle_events.rb +39 -0
- data/app/models/reviewkit/application_record.rb +7 -0
- data/app/models/reviewkit/comment.rb +29 -0
- data/app/models/reviewkit/current.rb +7 -0
- data/app/models/reviewkit/document.rb +79 -0
- data/app/models/reviewkit/review.rb +66 -0
- data/app/models/reviewkit/review_thread.rb +75 -0
- data/app/services/reviewkit/diffs/intraline_budget.rb +40 -0
- data/app/services/reviewkit/diffs/intraline_diff.rb +220 -0
- data/app/services/reviewkit/diffs/split_diff.rb +112 -0
- data/app/services/reviewkit/reviews/create.rb +57 -0
- data/app/views/layouts/reviewkit/application.html.erb +15 -0
- data/app/views/reviewkit/comments/_comment.html.erb +53 -0
- data/app/views/reviewkit/comments/_edit_form.html.erb +26 -0
- data/app/views/reviewkit/comments/_form.html.erb +16 -0
- data/app/views/reviewkit/review_threads/_bucket.html.erb +53 -0
- data/app/views/reviewkit/review_threads/_bucket_frame.html.erb +13 -0
- data/app/views/reviewkit/review_threads/_bucket_row.html.erb +55 -0
- data/app/views/reviewkit/review_threads/_edit_form.html.erb +29 -0
- data/app/views/reviewkit/review_threads/_thread.html.erb +87 -0
- data/app/views/reviewkit/reviews/_document.html.erb +41 -0
- data/app/views/reviewkit/reviews/_document_split.html.erb +73 -0
- data/app/views/reviewkit/reviews/_document_unified.html.erb +57 -0
- data/app/views/reviewkit/reviews/_edit_form.html.erb +35 -0
- data/app/views/reviewkit/reviews/_index_content.html.erb +160 -0
- data/app/views/reviewkit/reviews/_review_sidebar.html.erb +70 -0
- data/app/views/reviewkit/reviews/_show_content.html.erb +164 -0
- data/app/views/reviewkit/reviews/index.html.erb +11 -0
- data/app/views/reviewkit/reviews/show.html.erb +11 -0
- data/app/views/reviewkit/shared/_flash.html.erb +10 -0
- data/bin/console +4 -0
- data/bin/lint +4 -0
- data/bin/rails +14 -0
- data/bin/setup +9 -0
- data/bin/test +4 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +24 -0
- data/db/migrate/20260331181500_create_reviewkit_reviews.rb +19 -0
- data/db/migrate/20260331181600_create_reviewkit_documents.rb +23 -0
- data/db/migrate/20260331181700_create_reviewkit_review_threads.rb +23 -0
- data/db/migrate/20260331181800_create_reviewkit_comments.rb +15 -0
- data/db/migrate/20260401093000_add_description_to_reviewkit_reviews.rb +7 -0
- data/lib/generators/reviewkit/controllers/controllers_generator.rb +24 -0
- data/lib/generators/reviewkit/controllers/templates/comments_controller_extension.rb +13 -0
- data/lib/generators/reviewkit/controllers/templates/review_threads_controller_extension.rb +13 -0
- data/lib/generators/reviewkit/controllers/templates/reviews_controller_extension.rb +19 -0
- data/lib/generators/reviewkit/install/install_generator.rb +52 -0
- data/lib/generators/reviewkit/install/templates/importmap.rb +3 -0
- data/lib/generators/reviewkit/install/templates/reviewkit.rb +19 -0
- data/lib/generators/reviewkit/models/models_generator.rb +24 -0
- data/lib/generators/reviewkit/models/templates/comment_extension.rb +21 -0
- data/lib/generators/reviewkit/models/templates/review_extension.rb +22 -0
- data/lib/generators/reviewkit/models/templates/review_thread_extension.rb +21 -0
- data/lib/generators/reviewkit/views/views_generator.rb +15 -0
- data/lib/reviewkit/configuration.rb +33 -0
- data/lib/reviewkit/engine.rb +67 -0
- data/lib/reviewkit/version.rb +5 -0
- data/lib/reviewkit.rb +26 -0
- data/lib/tasks/reviewkit_tasks.rake +12 -0
- data/sig/reviewkit.rbs +129 -0
- metadata +238 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "rouge"
|
|
5
|
+
|
|
6
|
+
module Reviewkit
|
|
7
|
+
module DiffHelper
|
|
8
|
+
def reviewkit_document_badge_class(document)
|
|
9
|
+
case document.status
|
|
10
|
+
when "added"
|
|
11
|
+
"bg-emerald-100 text-emerald-800"
|
|
12
|
+
when "removed"
|
|
13
|
+
"bg-rose-100 text-rose-800"
|
|
14
|
+
when "modified"
|
|
15
|
+
"bg-amber-100 text-amber-900"
|
|
16
|
+
else
|
|
17
|
+
"bg-slate-200 text-slate-700"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reviewkit_status_pill_class(review_or_status)
|
|
22
|
+
status = review_or_status.respond_to?(:status) ? review_or_status.status : review_or_status.to_s
|
|
23
|
+
|
|
24
|
+
case status
|
|
25
|
+
when "approved"
|
|
26
|
+
"reviewkit-status-pill reviewkit-status-pill--approved"
|
|
27
|
+
when "rejected"
|
|
28
|
+
"reviewkit-status-pill reviewkit-status-pill--rejected"
|
|
29
|
+
when "closed"
|
|
30
|
+
"reviewkit-status-pill reviewkit-status-pill--closed"
|
|
31
|
+
when "resolved"
|
|
32
|
+
"reviewkit-status-pill reviewkit-status-pill--resolved"
|
|
33
|
+
when "outdated"
|
|
34
|
+
"reviewkit-status-pill reviewkit-status-pill--outdated"
|
|
35
|
+
when "draft"
|
|
36
|
+
"reviewkit-status-pill reviewkit-status-pill--draft"
|
|
37
|
+
else
|
|
38
|
+
"reviewkit-status-pill"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reviewkit_diff_row_class(row)
|
|
43
|
+
"reviewkit-line reviewkit-line--#{row.fetch("kind")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reviewkit_unified_group_rows(row, document: nil)
|
|
47
|
+
inline_changes =
|
|
48
|
+
if reviewkit_inline_source_kind(row) == "changed"
|
|
49
|
+
reviewkit_inline_changes(row, document: document)
|
|
50
|
+
else
|
|
51
|
+
row["inline_changes"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
case row.fetch("kind")
|
|
55
|
+
when "changed"
|
|
56
|
+
[
|
|
57
|
+
reviewkit_unified_row_payload(row, side: "old", kind: "removed", line: row["old_line"], text: row["old_text"], inline_changes: inline_changes),
|
|
58
|
+
reviewkit_unified_row_payload(row, side: "new", kind: "added", line: row["new_line"], text: row["new_text"], inline_changes: inline_changes)
|
|
59
|
+
]
|
|
60
|
+
when "removed"
|
|
61
|
+
[ reviewkit_unified_row_payload(row, side: "old", kind: "removed", line: row["old_line"], text: row["old_text"], inline_changes: inline_changes) ]
|
|
62
|
+
when "added"
|
|
63
|
+
[ reviewkit_unified_row_payload(row, side: "new", kind: "added", line: row["new_line"], text: row["new_text"], inline_changes: inline_changes) ]
|
|
64
|
+
else
|
|
65
|
+
[ reviewkit_unified_row_payload(row, side: "context", kind: "context", line: row["new_line"] || row["old_line"], text: row["new_text"].presence || row["old_text"], inline_changes: inline_changes) ]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reviewkit_inline_ranges(row, side:, document: nil)
|
|
70
|
+
return [] unless reviewkit_inline_source_kind(row) == "changed"
|
|
71
|
+
|
|
72
|
+
normalized_side = side.to_s
|
|
73
|
+
return [] unless %w[old new].include?(normalized_side)
|
|
74
|
+
|
|
75
|
+
Array(reviewkit_inline_changes(row, document: document)[normalized_side])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reviewkit_highlight_line(text, language, inline_ranges: nil, inline_side: nil)
|
|
79
|
+
return " ".html_safe if text.blank?
|
|
80
|
+
|
|
81
|
+
lexer = Rouge::Lexer.find_fancy(language.to_s, text) || Rouge::Lexers::PlainText.new
|
|
82
|
+
formatter = Rouge::Formatters::HTML.new
|
|
83
|
+
highlighted_html = formatter.format(lexer.lex(text))
|
|
84
|
+
rendered_html = reviewkit_apply_inline_ranges(highlighted_html, inline_ranges, inline_side)
|
|
85
|
+
|
|
86
|
+
content_tag(:span, rendered_html, class: "highlight")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reviewkit_line_number(value)
|
|
90
|
+
value.presence || " ".html_safe
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def reviewkit_render_comment_body(comment)
|
|
94
|
+
simple_format(h(comment.body), class: "reviewkit-comment-body")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def reviewkit_unified_row_payload(row, side:, kind:, line:, text:, inline_changes:)
|
|
100
|
+
{
|
|
101
|
+
"kind" => kind,
|
|
102
|
+
"line_code" => row.fetch("line_code"),
|
|
103
|
+
"new_line" => side == "old" ? nil : row["new_line"],
|
|
104
|
+
"new_text" => side == "old" ? "" : row["new_text"].to_s,
|
|
105
|
+
"old_line" => side == "new" ? nil : row["old_line"],
|
|
106
|
+
"old_text" => side == "new" ? "" : row["old_text"].to_s,
|
|
107
|
+
"inline_changes" => inline_changes,
|
|
108
|
+
"side" => side,
|
|
109
|
+
"source_kind" => row.fetch("kind"),
|
|
110
|
+
"text" => text.to_s,
|
|
111
|
+
"line" => line
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reviewkit_inline_source_kind(row)
|
|
116
|
+
row["source_kind"].presence || row["kind"].to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def reviewkit_inline_changes(row, document: nil)
|
|
120
|
+
row["inline_changes"] ||= begin
|
|
121
|
+
context = reviewkit_intraline_context(document)
|
|
122
|
+
old_text = row["old_text"].to_s
|
|
123
|
+
new_text = row["new_text"].to_s
|
|
124
|
+
|
|
125
|
+
if Reviewkit::Diffs::IntralineBudget.allow?(
|
|
126
|
+
old_text: old_text,
|
|
127
|
+
new_text: new_text,
|
|
128
|
+
review_document_count: context[:review_document_count],
|
|
129
|
+
changed_row_count: context[:changed_row_count]
|
|
130
|
+
)
|
|
131
|
+
Reviewkit::Diffs::IntralineDiff.call(old_text:, new_text:)
|
|
132
|
+
else
|
|
133
|
+
reviewkit_empty_inline_changes
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def reviewkit_intraline_context(document)
|
|
139
|
+
return {} if document.blank?
|
|
140
|
+
|
|
141
|
+
document.instance_variable_get(:@reviewkit_intraline_context) || begin
|
|
142
|
+
context = {
|
|
143
|
+
changed_row_count: document.diff_rows.count { |row| row["kind"] == "changed" },
|
|
144
|
+
review_document_count: document.review&.documents&.size
|
|
145
|
+
}
|
|
146
|
+
document.instance_variable_set(:@reviewkit_intraline_context, context)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reviewkit_empty_inline_changes
|
|
151
|
+
{ "old" => [], "new" => [] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def reviewkit_apply_inline_ranges(highlighted_html, inline_ranges, inline_side)
|
|
155
|
+
normalized_side = inline_side.to_s
|
|
156
|
+
normalized_ranges = reviewkit_merge_inline_ranges(Array(inline_ranges))
|
|
157
|
+
return highlighted_html.html_safe if normalized_ranges.empty? || !%w[old new].include?(normalized_side)
|
|
158
|
+
|
|
159
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(highlighted_html)
|
|
160
|
+
document = fragment.document
|
|
161
|
+
text_offset = 0
|
|
162
|
+
range_index = 0
|
|
163
|
+
|
|
164
|
+
fragment.xpath(".//text()").each do |node|
|
|
165
|
+
node_text = node.text
|
|
166
|
+
node_start = text_offset
|
|
167
|
+
node_end = node_start + node_text.length
|
|
168
|
+
text_offset = node_end
|
|
169
|
+
|
|
170
|
+
next if node_text.empty?
|
|
171
|
+
|
|
172
|
+
range_index += 1 while range_index < normalized_ranges.length && normalized_ranges[range_index].fetch("end") <= node_start
|
|
173
|
+
|
|
174
|
+
node_ranges = []
|
|
175
|
+
index = range_index
|
|
176
|
+
|
|
177
|
+
while index < normalized_ranges.length && normalized_ranges[index].fetch("start") < node_end
|
|
178
|
+
node_ranges << normalized_ranges[index]
|
|
179
|
+
index += 1
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
next if node_ranges.empty?
|
|
183
|
+
|
|
184
|
+
cursor = 0
|
|
185
|
+
node_ranges.each do |range|
|
|
186
|
+
local_start = [ range.fetch("start") - node_start, 0 ].max
|
|
187
|
+
local_end = [ range.fetch("end") - node_start, node_text.length ].min
|
|
188
|
+
next if local_start >= local_end
|
|
189
|
+
|
|
190
|
+
if local_start > cursor
|
|
191
|
+
node.add_previous_sibling(Nokogiri::XML::Text.new(node_text[cursor...local_start], document))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
wrapper = Nokogiri::XML::Node.new("span", document)
|
|
195
|
+
wrapper["class"] = "reviewkit-inline-change reviewkit-inline-change--#{normalized_side}"
|
|
196
|
+
wrapper.content = node_text[local_start...local_end]
|
|
197
|
+
node.add_previous_sibling(wrapper)
|
|
198
|
+
cursor = local_end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if cursor < node_text.length
|
|
202
|
+
node.add_previous_sibling(Nokogiri::XML::Text.new(node_text[cursor..], document))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
node.remove
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
fragment.to_html.html_safe
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def reviewkit_merge_inline_ranges(ranges)
|
|
212
|
+
ranges
|
|
213
|
+
.map do |range|
|
|
214
|
+
{
|
|
215
|
+
"start" => range["start"] || range[:start],
|
|
216
|
+
"end" => range["end"] || range[:end]
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
.select { |range| range["start"].present? && range["end"].present? && range["start"] < range["end"] }
|
|
220
|
+
.sort_by { |range| [ range.fetch("start"), range.fetch("end") ] }
|
|
221
|
+
.each_with_object([]) do |range, merged|
|
|
222
|
+
if merged.empty? || range.fetch("start") > merged.last.fetch("end")
|
|
223
|
+
merged << range
|
|
224
|
+
else
|
|
225
|
+
merged.last["end"] = [ merged.last.fetch("end"), range.fetch("end") ].max
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module FlashHelper
|
|
5
|
+
def reviewkit_flash_class(type)
|
|
6
|
+
base_class = "reviewkit-flash"
|
|
7
|
+
|
|
8
|
+
case type.to_s
|
|
9
|
+
when "notice"
|
|
10
|
+
"#{base_class} #{base_class}--notice"
|
|
11
|
+
when "alert"
|
|
12
|
+
"#{base_class} #{base_class}--alert"
|
|
13
|
+
else
|
|
14
|
+
base_class
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reviewkit_flash_shell_class
|
|
19
|
+
base_class = "reviewkit-flash-shell"
|
|
20
|
+
|
|
21
|
+
if reviewkit_frame_request?
|
|
22
|
+
"#{base_class} #{base_class}--inline"
|
|
23
|
+
else
|
|
24
|
+
"#{base_class} #{base_class}--page"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reviewkit_flash_label(type)
|
|
29
|
+
type.to_s.humanize
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reviewkit_flash_role(type)
|
|
33
|
+
type.to_s == "alert" ? "alert" : "status"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module FrameHelper
|
|
5
|
+
def reviewkit_requested_frame_id
|
|
6
|
+
request.headers["Turbo-Frame"].presence || params[:reviewkit_frame_id].presence
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def reviewkit_frame_request?
|
|
10
|
+
reviewkit_requested_frame_id.present?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def reviewkit_wrap_in_frame(&block)
|
|
14
|
+
content = capture(&block)
|
|
15
|
+
frame_id = reviewkit_requested_frame_id
|
|
16
|
+
return content if frame_id.blank?
|
|
17
|
+
|
|
18
|
+
turbo_frame_tag(frame_id) { content }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reviewkit_document_anchor(document)
|
|
22
|
+
"document-#{document.id}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reviewkit_review_frame_id(review)
|
|
26
|
+
reviewkit_requested_frame_id.presence || dom_id(review, :review)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reviewkit_document_path_parts(document_or_path)
|
|
30
|
+
path = document_or_path.respond_to?(:path) ? document_or_path.path.to_s : document_or_path.to_s
|
|
31
|
+
directory = File.dirname(path)
|
|
32
|
+
directory = nil if directory.blank? || directory == "."
|
|
33
|
+
|
|
34
|
+
[ directory ? "#{directory}/" : nil, File.basename(path) ]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module IconHelper
|
|
5
|
+
def reviewkit_file_icon(class_name: "reviewkit-file-icon")
|
|
6
|
+
content_tag(
|
|
7
|
+
:svg,
|
|
8
|
+
safe_join([
|
|
9
|
+
tag.path(
|
|
10
|
+
d: "M4.75 1.5h4.9L12.5 4.35v7.9a1.25 1.25 0 0 1-1.25 1.25h-6.5A1.25 1.25 0 0 1 3.5 12.25v-9.5A1.25 1.25 0 0 1 4.75 1.5Z",
|
|
11
|
+
fill: "none",
|
|
12
|
+
stroke: "currentColor",
|
|
13
|
+
"stroke-linecap": "round",
|
|
14
|
+
"stroke-linejoin": "round",
|
|
15
|
+
"stroke-width": "1.1"
|
|
16
|
+
),
|
|
17
|
+
tag.path(
|
|
18
|
+
d: "M9.5 1.75v2.5H12",
|
|
19
|
+
fill: "none",
|
|
20
|
+
stroke: "currentColor",
|
|
21
|
+
"stroke-linecap": "round",
|
|
22
|
+
"stroke-linejoin": "round",
|
|
23
|
+
"stroke-width": "1.1"
|
|
24
|
+
)
|
|
25
|
+
]),
|
|
26
|
+
class: class_name,
|
|
27
|
+
viewBox: "0 0 16 16",
|
|
28
|
+
fill: "none",
|
|
29
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
30
|
+
"aria-hidden": "true"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reviewkit_chevron_right_icon(class_name: "reviewkit-inline-icon")
|
|
35
|
+
content_tag(
|
|
36
|
+
:svg,
|
|
37
|
+
tag.path(
|
|
38
|
+
d: "M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z",
|
|
39
|
+
"clip-rule": "evenodd",
|
|
40
|
+
"fill-rule": "evenodd"
|
|
41
|
+
),
|
|
42
|
+
class: class_name,
|
|
43
|
+
viewBox: "0 0 20 20",
|
|
44
|
+
fill: "currentColor",
|
|
45
|
+
"aria-hidden": "true"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reviewkit_pencil_icon(class_name: "reviewkit-inline-icon")
|
|
50
|
+
content_tag(
|
|
51
|
+
:svg,
|
|
52
|
+
safe_join([
|
|
53
|
+
tag.path(
|
|
54
|
+
d: "M16.862 3.487a2.25 2.25 0 0 0-3.182 0l-8.25 8.25a2.25 2.25 0 0 0-.588 1.06l-.74 3.333a.75.75 0 0 0 .895.895l3.333-.74a2.25 2.25 0 0 0 1.06-.588l8.25-8.25a2.25 2.25 0 0 0 0-3.182Z",
|
|
55
|
+
fill: "none",
|
|
56
|
+
stroke: "currentColor",
|
|
57
|
+
"stroke-linecap": "round",
|
|
58
|
+
"stroke-linejoin": "round",
|
|
59
|
+
"stroke-width": "1.5"
|
|
60
|
+
),
|
|
61
|
+
tag.path(
|
|
62
|
+
d: "m12.75 4.5 2.75 2.75",
|
|
63
|
+
fill: "none",
|
|
64
|
+
stroke: "currentColor",
|
|
65
|
+
"stroke-linecap": "round",
|
|
66
|
+
"stroke-linejoin": "round",
|
|
67
|
+
"stroke-width": "1.5"
|
|
68
|
+
)
|
|
69
|
+
]),
|
|
70
|
+
class: class_name,
|
|
71
|
+
viewBox: "0 0 20 20",
|
|
72
|
+
fill: "none",
|
|
73
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
74
|
+
"aria-hidden": "true"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reviewkit_trash_icon(class_name: "reviewkit-inline-icon")
|
|
79
|
+
content_tag(
|
|
80
|
+
:svg,
|
|
81
|
+
safe_join([
|
|
82
|
+
tag.path(
|
|
83
|
+
d: "M7.5 2.75h5a.75.75 0 0 1 .75.75V5h3.25a.75.75 0 0 1 0 1.5h-.69l-.63 9.12A2.25 2.25 0 0 1 12.94 17.75H7.06a2.25 2.25 0 0 1-2.24-2.13L4.19 6.5H3.5a.75.75 0 0 1 0-1.5h3.25V3.5a.75.75 0 0 1 .75-.75Z",
|
|
84
|
+
fill: "none",
|
|
85
|
+
stroke: "currentColor",
|
|
86
|
+
"stroke-linecap": "round",
|
|
87
|
+
"stroke-linejoin": "round",
|
|
88
|
+
"stroke-width": "1.5"
|
|
89
|
+
),
|
|
90
|
+
tag.path(
|
|
91
|
+
d: "M8.5 8.25v5.5M11.5 8.25v5.5",
|
|
92
|
+
fill: "none",
|
|
93
|
+
stroke: "currentColor",
|
|
94
|
+
"stroke-linecap": "round",
|
|
95
|
+
"stroke-linejoin": "round",
|
|
96
|
+
"stroke-width": "1.5"
|
|
97
|
+
)
|
|
98
|
+
]),
|
|
99
|
+
class: class_name,
|
|
100
|
+
viewBox: "0 0 20 20",
|
|
101
|
+
fill: "none",
|
|
102
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
103
|
+
"aria-hidden": "true"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module ReviewThreadHelper
|
|
5
|
+
def reviewkit_actor_label(actor)
|
|
6
|
+
return "System" unless actor
|
|
7
|
+
return actor.name if actor.respond_to?(:name) && actor.name.present?
|
|
8
|
+
return actor.email if actor.respond_to?(:email) && actor.email.present?
|
|
9
|
+
|
|
10
|
+
actor.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def reviewkit_thread_bucket_id(document, line_code)
|
|
14
|
+
"reviewkit-document-#{document.id}-line-#{line_code}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reviewkit_thread_bucket_row_id(document, line_code)
|
|
18
|
+
"#{reviewkit_thread_bucket_id(document, line_code)}-row"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reviewkit_threads_for(thread_index, document, row)
|
|
22
|
+
Array(thread_index[[ document.id, row.fetch("line_code") ]])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reviewkit_thread_starter_comment(thread)
|
|
26
|
+
thread.comments.min_by { |comment| [ comment.created_at, comment.id ] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reviewkit_thread_preview(thread)
|
|
30
|
+
truncate(reviewkit_thread_starter_comment(thread)&.body.to_s, length: 120)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reviewkit_thread_manageable?(thread)
|
|
34
|
+
starter_comment = reviewkit_thread_starter_comment(thread)
|
|
35
|
+
return false unless starter_comment
|
|
36
|
+
|
|
37
|
+
reviewkit_comment_manageable?(starter_comment)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reviewkit_comment_manageable?(comment)
|
|
41
|
+
comment.author == reviewkit_current_actor
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reviewkit_comment_destroyable?(comment)
|
|
45
|
+
return false unless reviewkit_comment_manageable?(comment)
|
|
46
|
+
|
|
47
|
+
comment.review_thread.comments.size > 1 || comment.review_thread.comments.first == comment
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reviewkit_comment_edited?(comment)
|
|
51
|
+
comment.updated_at.present? && comment.created_at.present? && comment.updated_at > comment.created_at
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module NotifiesLifecycleEvents
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
after_create_commit -> { instrument_reviewkit_event(:created) }
|
|
9
|
+
after_update_commit :instrument_reviewkit_update_events
|
|
10
|
+
after_destroy_commit -> { instrument_reviewkit_event(:destroyed) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def instrument_reviewkit_update_events
|
|
16
|
+
instrument_reviewkit_event(:updated)
|
|
17
|
+
return unless respond_to?(:saved_change_to_status?) && saved_change_to_status?
|
|
18
|
+
|
|
19
|
+
from, to = saved_change_to_status
|
|
20
|
+
instrument_reviewkit_event(:status_changed, from:, to:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def instrument_reviewkit_event(event, **payload)
|
|
24
|
+
ActiveSupport::Notifications.instrument(
|
|
25
|
+
"reviewkit.#{self.class.model_name.element}.#{event}",
|
|
26
|
+
reviewkit_notification_payload.merge(payload.compact)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reviewkit_notification_payload
|
|
31
|
+
{
|
|
32
|
+
actor: Reviewkit::Current.actor,
|
|
33
|
+
controller: Reviewkit::Current.controller,
|
|
34
|
+
record: self,
|
|
35
|
+
source: Reviewkit::Current.source
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
class Comment < ApplicationRecord
|
|
5
|
+
include NotifiesLifecycleEvents
|
|
6
|
+
|
|
7
|
+
belongs_to :review_thread, inverse_of: :comments
|
|
8
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
9
|
+
|
|
10
|
+
delegate :document, :review, to: :review_thread
|
|
11
|
+
|
|
12
|
+
validates :body, presence: true
|
|
13
|
+
validate :metadata_must_be_hash
|
|
14
|
+
|
|
15
|
+
before_validation :normalize_metadata
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def normalize_metadata
|
|
20
|
+
self.metadata = metadata.deep_stringify_keys if metadata.is_a?(Hash)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def metadata_must_be_hash
|
|
24
|
+
return if metadata.nil? || metadata.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
errors.add(:metadata, "must be a hash")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
class Document < ApplicationRecord
|
|
5
|
+
attr_accessor :intraline_review_document_count
|
|
6
|
+
|
|
7
|
+
enum :status, {
|
|
8
|
+
added: "added",
|
|
9
|
+
removed: "removed",
|
|
10
|
+
modified: "modified",
|
|
11
|
+
unchanged: "unchanged"
|
|
12
|
+
}, validate: true
|
|
13
|
+
|
|
14
|
+
belongs_to :review, inverse_of: :documents
|
|
15
|
+
has_many :review_threads, dependent: :destroy, inverse_of: :document
|
|
16
|
+
|
|
17
|
+
validates :language, presence: true
|
|
18
|
+
validates :path, presence: true
|
|
19
|
+
validates :position, numericality: { greater_than_or_equal_to: 0 }
|
|
20
|
+
validates :path, uniqueness: { scope: :review_id }
|
|
21
|
+
validate :metadata_must_be_hash
|
|
22
|
+
|
|
23
|
+
before_validation :assign_status
|
|
24
|
+
before_validation :refresh_diff_cache
|
|
25
|
+
before_validation :normalize_metadata
|
|
26
|
+
|
|
27
|
+
def additions_count
|
|
28
|
+
diff_cache.dig("stats", "additions").to_i
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def deletions_count
|
|
32
|
+
diff_cache.dig("stats", "deletions").to_i
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def diff_rows
|
|
36
|
+
Array(diff_cache["rows"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def assign_status
|
|
42
|
+
self.status =
|
|
43
|
+
if old_content.blank? && new_content.present?
|
|
44
|
+
"added"
|
|
45
|
+
elsif old_content.present? && new_content.blank?
|
|
46
|
+
"removed"
|
|
47
|
+
elsif old_content == new_content
|
|
48
|
+
"unchanged"
|
|
49
|
+
else
|
|
50
|
+
"modified"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def refresh_diff_cache
|
|
55
|
+
self.diff_cache = Reviewkit::Diffs::SplitDiff.call(
|
|
56
|
+
old_content: old_content.to_s,
|
|
57
|
+
new_content: new_content.to_s,
|
|
58
|
+
review_document_count: resolved_review_document_count
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolved_review_document_count
|
|
63
|
+
return intraline_review_document_count if intraline_review_document_count.present?
|
|
64
|
+
return unless review
|
|
65
|
+
|
|
66
|
+
review.documents.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize_metadata
|
|
70
|
+
self.metadata = metadata.deep_stringify_keys if metadata.is_a?(Hash)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def metadata_must_be_hash
|
|
74
|
+
return if metadata.nil? || metadata.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
errors.add(:metadata, "must be a hash")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
class Review < ApplicationRecord
|
|
5
|
+
include NotifiesLifecycleEvents
|
|
6
|
+
|
|
7
|
+
enum :status, {
|
|
8
|
+
draft: "draft",
|
|
9
|
+
open: "open",
|
|
10
|
+
approved: "approved",
|
|
11
|
+
rejected: "rejected",
|
|
12
|
+
closed: "closed"
|
|
13
|
+
}, validate: true
|
|
14
|
+
|
|
15
|
+
belongs_to :reviewable, polymorphic: true, optional: true
|
|
16
|
+
belongs_to :creator, polymorphic: true, optional: true
|
|
17
|
+
has_many :documents, -> { order(:position, :id) }, dependent: :destroy, inverse_of: :review
|
|
18
|
+
has_many :review_threads, dependent: :destroy, inverse_of: :review
|
|
19
|
+
|
|
20
|
+
validates :description, length: { maximum: 10_000 }, allow_blank: true
|
|
21
|
+
validates :title, presence: true
|
|
22
|
+
validate :metadata_must_be_hash
|
|
23
|
+
|
|
24
|
+
validate :final_status_requires_all_threads_resolved, if: :will_save_change_to_status?
|
|
25
|
+
before_validation :normalize_metadata
|
|
26
|
+
|
|
27
|
+
def open_threads_count
|
|
28
|
+
review_threads.open.count
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolved_threads_count
|
|
32
|
+
review_threads.resolved.count
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def approve!
|
|
36
|
+
update!(status: "approved")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reject!
|
|
40
|
+
update!(status: "rejected")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close!
|
|
44
|
+
update!(status: "closed")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def normalize_metadata
|
|
50
|
+
self.metadata = metadata.deep_stringify_keys if metadata.is_a?(Hash)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def final_status_requires_all_threads_resolved
|
|
54
|
+
return unless approved? || closed?
|
|
55
|
+
return unless review_threads.open.exists?
|
|
56
|
+
|
|
57
|
+
errors.add(:status, "cannot change while open threads remain")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def metadata_must_be_hash
|
|
61
|
+
return if metadata.nil? || metadata.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
errors.add(:metadata, "must be a hash")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|