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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
class ReviewThread < ApplicationRecord
|
|
5
|
+
include NotifiesLifecycleEvents
|
|
6
|
+
|
|
7
|
+
enum :status, {
|
|
8
|
+
open: "open",
|
|
9
|
+
resolved: "resolved",
|
|
10
|
+
outdated: "outdated"
|
|
11
|
+
}, validate: true
|
|
12
|
+
|
|
13
|
+
belongs_to :review, inverse_of: :review_threads
|
|
14
|
+
belongs_to :document, inverse_of: :review_threads
|
|
15
|
+
belongs_to :resolved_by, polymorphic: true, optional: true
|
|
16
|
+
has_many :comments, -> { order(:created_at, :id) }, dependent: :destroy, inverse_of: :review_thread
|
|
17
|
+
|
|
18
|
+
validates :line_code, presence: true
|
|
19
|
+
validates :side, presence: true
|
|
20
|
+
validates :side, inclusion: { in: %w[old new] }
|
|
21
|
+
validate :anchor_line_must_match_side
|
|
22
|
+
validate :document_must_belong_to_review
|
|
23
|
+
validate :metadata_must_be_hash
|
|
24
|
+
|
|
25
|
+
before_validation :assign_review_from_document
|
|
26
|
+
before_validation :normalize_metadata
|
|
27
|
+
|
|
28
|
+
scope :open, -> { where(status: "open") }
|
|
29
|
+
scope :outdated, -> { where(status: "outdated") }
|
|
30
|
+
scope :resolved, -> { where(status: "resolved") }
|
|
31
|
+
|
|
32
|
+
def resolve!
|
|
33
|
+
update!(status: "resolved", resolved_at: Time.current, resolved_by: Reviewkit::Current.actor)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reopen!
|
|
37
|
+
update!(status: "open", resolved_at: nil, resolved_by: nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mark_outdated!
|
|
41
|
+
update!(status: "outdated", resolved_at: nil, resolved_by: nil)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def assign_review_from_document
|
|
47
|
+
self.review ||= document&.review
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_metadata
|
|
51
|
+
self.metadata = metadata.deep_stringify_keys if metadata.is_a?(Hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def anchor_line_must_match_side
|
|
55
|
+
return if side.blank?
|
|
56
|
+
return if side == "old" && old_line.present?
|
|
57
|
+
return if side == "new" && new_line.present?
|
|
58
|
+
|
|
59
|
+
errors.add(:base, "must include a #{side} line anchor")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def document_must_belong_to_review
|
|
63
|
+
return if review.blank? || document.blank?
|
|
64
|
+
return if document.review_id == review_id
|
|
65
|
+
|
|
66
|
+
errors.add(:document, "must belong to the same review")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def metadata_must_be_hash
|
|
70
|
+
return if metadata.nil? || metadata.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
errors.add(:metadata, "must be a hash")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module Diffs
|
|
5
|
+
class IntralineBudget
|
|
6
|
+
def self.allow?(...)
|
|
7
|
+
new(...).allow?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(old_text:, new_text:, review_document_count: nil, changed_row_count: nil, limits: Reviewkit.config.intraline_limits)
|
|
11
|
+
@old_text = old_text.to_s
|
|
12
|
+
@new_text = new_text.to_s
|
|
13
|
+
@review_document_count = review_document_count
|
|
14
|
+
@changed_row_count = changed_row_count
|
|
15
|
+
@limits = limits
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def allow?
|
|
19
|
+
return false unless @limits.enabled
|
|
20
|
+
return false if exceeds_limit?(@review_document_count, @limits.max_review_files)
|
|
21
|
+
return false if exceeds_limit?(@changed_row_count, @limits.max_changed_lines)
|
|
22
|
+
return false if exceeds_line_length_limit?
|
|
23
|
+
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def exceeds_limit?(value, limit)
|
|
30
|
+
value.present? && limit.present? && value > limit
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exceeds_line_length_limit?
|
|
34
|
+
return false unless @limits.max_line_length.present?
|
|
35
|
+
|
|
36
|
+
[ @old_text.length, @new_text.length ].max > @limits.max_line_length
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "diff/lcs"
|
|
4
|
+
|
|
5
|
+
module Reviewkit
|
|
6
|
+
module Diffs
|
|
7
|
+
class IntralineDiff
|
|
8
|
+
SIMILARITY_THRESHOLD = 0.5
|
|
9
|
+
TOKEN_PATTERN = /\s+|[[:alnum:]_]+|[^[:alnum:]_\s]+/
|
|
10
|
+
|
|
11
|
+
def self.call(...)
|
|
12
|
+
new(...).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(old_text:, new_text:)
|
|
16
|
+
@old_text = old_text.to_s
|
|
17
|
+
@new_text = new_text.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
return empty_ranges if @old_text == @new_text
|
|
22
|
+
|
|
23
|
+
old_tokens = tokenize(@old_text)
|
|
24
|
+
new_tokens = tokenize(@new_text)
|
|
25
|
+
ranges = empty_ranges
|
|
26
|
+
pending_old = []
|
|
27
|
+
pending_new = []
|
|
28
|
+
old_index = 0
|
|
29
|
+
new_index = 0
|
|
30
|
+
shared_non_whitespace_chars = 0
|
|
31
|
+
|
|
32
|
+
Diff::LCS.sdiff(token_texts(old_tokens), token_texts(new_tokens)).each do |change|
|
|
33
|
+
case change.action
|
|
34
|
+
when "="
|
|
35
|
+
shared_non_whitespace_chars += append_group_ranges(ranges, pending_old, pending_new)
|
|
36
|
+
pending_old = []
|
|
37
|
+
pending_new = []
|
|
38
|
+
shared_non_whitespace_chars += non_whitespace_character_count(old_tokens.fetch(old_index).fetch("text"))
|
|
39
|
+
old_index += 1
|
|
40
|
+
new_index += 1
|
|
41
|
+
when "!"
|
|
42
|
+
pending_old << old_tokens.fetch(old_index)
|
|
43
|
+
pending_new << new_tokens.fetch(new_index)
|
|
44
|
+
old_index += 1
|
|
45
|
+
new_index += 1
|
|
46
|
+
when "-"
|
|
47
|
+
pending_old << old_tokens.fetch(old_index)
|
|
48
|
+
old_index += 1
|
|
49
|
+
when "+"
|
|
50
|
+
pending_new << new_tokens.fetch(new_index)
|
|
51
|
+
new_index += 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
shared_non_whitespace_chars += append_group_ranges(ranges, pending_old, pending_new)
|
|
56
|
+
normalized_ranges = normalize_ranges(ranges)
|
|
57
|
+
|
|
58
|
+
return normalized_ranges if meets_similarity_threshold?(shared_non_whitespace_chars)
|
|
59
|
+
|
|
60
|
+
empty_ranges
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def tokenize(text)
|
|
66
|
+
cursor = 0
|
|
67
|
+
|
|
68
|
+
text.to_enum(:scan, TOKEN_PATTERN).map do
|
|
69
|
+
token_text = Regexp.last_match(0)
|
|
70
|
+
token = {
|
|
71
|
+
"kind" => token_kind(token_text),
|
|
72
|
+
"text" => token_text,
|
|
73
|
+
"start" => cursor,
|
|
74
|
+
"end" => cursor + token_text.length
|
|
75
|
+
}
|
|
76
|
+
cursor = token.fetch("end")
|
|
77
|
+
token
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def token_texts(tokens)
|
|
82
|
+
tokens.map { |token| token.fetch("text") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def token_kind(token_text)
|
|
86
|
+
return "whitespace" if token_text.match?(/\A\s+\z/)
|
|
87
|
+
return "word" if token_text.match?(/\A[[:alnum:]_]+\z/)
|
|
88
|
+
|
|
89
|
+
"punctuation"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def append_group_ranges(ranges, old_tokens, new_tokens)
|
|
93
|
+
return 0 if old_tokens.empty? && new_tokens.empty?
|
|
94
|
+
|
|
95
|
+
if old_tokens.empty?
|
|
96
|
+
ranges["new"] << range_from_tokens(new_tokens)
|
|
97
|
+
return 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if new_tokens.empty?
|
|
101
|
+
ranges["old"] << range_from_tokens(old_tokens)
|
|
102
|
+
return 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if refine_single_token_pair?(old_tokens, new_tokens)
|
|
106
|
+
return append_character_ranges(ranges, old_tokens.first, new_tokens.first)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
ranges["old"] << range_from_tokens(old_tokens)
|
|
110
|
+
ranges["new"] << range_from_tokens(new_tokens)
|
|
111
|
+
0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def refine_single_token_pair?(old_tokens, new_tokens)
|
|
115
|
+
return false unless old_tokens.one? && new_tokens.one?
|
|
116
|
+
|
|
117
|
+
old_token = old_tokens.first
|
|
118
|
+
new_token = new_tokens.first
|
|
119
|
+
return true if old_token.fetch("kind") == "whitespace" || new_token.fetch("kind") == "whitespace"
|
|
120
|
+
return false unless old_token.fetch("kind") == new_token.fetch("kind")
|
|
121
|
+
|
|
122
|
+
old_text = old_token.fetch("text")
|
|
123
|
+
new_text = new_token.fetch("text")
|
|
124
|
+
|
|
125
|
+
old_text.start_with?(new_text) ||
|
|
126
|
+
new_text.start_with?(old_text) ||
|
|
127
|
+
old_text.end_with?(new_text) ||
|
|
128
|
+
new_text.end_with?(old_text) ||
|
|
129
|
+
common_prefix_length(old_text, new_text).positive? ||
|
|
130
|
+
common_suffix_length(old_text, new_text).positive?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def append_character_ranges(ranges, old_token, new_token)
|
|
134
|
+
old_offset = old_token.fetch("start")
|
|
135
|
+
new_offset = new_token.fetch("start")
|
|
136
|
+
old_index = 0
|
|
137
|
+
new_index = 0
|
|
138
|
+
shared_non_whitespace_chars = 0
|
|
139
|
+
|
|
140
|
+
Diff::LCS.sdiff(old_token.fetch("text").chars, new_token.fetch("text").chars).each do |change|
|
|
141
|
+
case change.action
|
|
142
|
+
when "="
|
|
143
|
+
shared_non_whitespace_chars += 1 unless change.old_element.match?(/\s/)
|
|
144
|
+
old_index += 1
|
|
145
|
+
new_index += 1
|
|
146
|
+
when "!"
|
|
147
|
+
ranges["old"] << build_range(old_offset + old_index, old_offset + old_index + 1)
|
|
148
|
+
ranges["new"] << build_range(new_offset + new_index, new_offset + new_index + 1)
|
|
149
|
+
old_index += 1
|
|
150
|
+
new_index += 1
|
|
151
|
+
when "-"
|
|
152
|
+
ranges["old"] << build_range(old_offset + old_index, old_offset + old_index + 1)
|
|
153
|
+
old_index += 1
|
|
154
|
+
when "+"
|
|
155
|
+
ranges["new"] << build_range(new_offset + new_index, new_offset + new_index + 1)
|
|
156
|
+
new_index += 1
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
shared_non_whitespace_chars
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def range_from_tokens(tokens)
|
|
164
|
+
build_range(tokens.first.fetch("start"), tokens.last.fetch("end"))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def normalize_ranges(ranges)
|
|
168
|
+
{
|
|
169
|
+
"old" => merge_ranges(Array(ranges["old"])),
|
|
170
|
+
"new" => merge_ranges(Array(ranges["new"]))
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def merge_ranges(ranges)
|
|
175
|
+
ranges
|
|
176
|
+
.map { |range| build_range(range.fetch("start"), range.fetch("end")) }
|
|
177
|
+
.reject { |range| range.fetch("start") >= range.fetch("end") }
|
|
178
|
+
.sort_by { |range| [ range.fetch("start"), range.fetch("end") ] }
|
|
179
|
+
.each_with_object([]) do |range, merged|
|
|
180
|
+
if merged.empty? || range.fetch("start") > merged.last.fetch("end")
|
|
181
|
+
merged << range
|
|
182
|
+
else
|
|
183
|
+
merged.last["end"] = [ merged.last.fetch("end"), range.fetch("end") ].max
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_range(start_index, end_index)
|
|
189
|
+
{ "start" => start_index, "end" => end_index }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def meets_similarity_threshold?(shared_non_whitespace_chars)
|
|
193
|
+
denominator = [
|
|
194
|
+
non_whitespace_character_count(@old_text),
|
|
195
|
+
non_whitespace_character_count(@new_text)
|
|
196
|
+
].max
|
|
197
|
+
|
|
198
|
+
return true if denominator.zero?
|
|
199
|
+
|
|
200
|
+
(shared_non_whitespace_chars.to_f / denominator) >= SIMILARITY_THRESHOLD
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def non_whitespace_character_count(text)
|
|
204
|
+
text.each_char.count { |character| !character.match?(/\s/) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def common_prefix_length(old_text, new_text)
|
|
208
|
+
old_text.chars.zip(new_text.chars).take_while { |old_char, new_char| old_char == new_char }.size
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def common_suffix_length(old_text, new_text)
|
|
212
|
+
old_text.chars.reverse.zip(new_text.chars.reverse).take_while { |old_char, new_char| old_char == new_char }.size
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def empty_ranges
|
|
216
|
+
{ "old" => [], "new" => [] }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "diff/lcs"
|
|
5
|
+
|
|
6
|
+
module Reviewkit
|
|
7
|
+
module Diffs
|
|
8
|
+
class SplitDiff
|
|
9
|
+
def self.call(...)
|
|
10
|
+
new(...).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(old_content:, new_content:, review_document_count: nil)
|
|
14
|
+
@old_content = old_content.to_s
|
|
15
|
+
@new_content = new_content.to_s
|
|
16
|
+
@review_document_count = review_document_count
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
old_line_number = 0
|
|
21
|
+
new_line_number = 0
|
|
22
|
+
stats = { "additions" => 0, "deletions" => 0, "changes" => 0, "context" => 0 }
|
|
23
|
+
changes = Diff::LCS.sdiff(lines(@old_content), lines(@new_content)).to_a
|
|
24
|
+
changed_row_count = changes.count { |change| change.action == "!" }
|
|
25
|
+
|
|
26
|
+
rows = changes.map do |change|
|
|
27
|
+
case change.action
|
|
28
|
+
when "="
|
|
29
|
+
old_line_number += 1
|
|
30
|
+
new_line_number += 1
|
|
31
|
+
stats["context"] += 1
|
|
32
|
+
build_row("context", old_line_number, new_line_number, change.old_element, change.new_element)
|
|
33
|
+
when "!"
|
|
34
|
+
old_line_number += 1
|
|
35
|
+
new_line_number += 1
|
|
36
|
+
stats["changes"] += 1
|
|
37
|
+
stats["additions"] += 1
|
|
38
|
+
stats["deletions"] += 1
|
|
39
|
+
inline_changes = build_inline_changes(
|
|
40
|
+
change.old_element,
|
|
41
|
+
change.new_element,
|
|
42
|
+
changed_row_count: changed_row_count
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
build_row(
|
|
46
|
+
"changed",
|
|
47
|
+
old_line_number,
|
|
48
|
+
new_line_number,
|
|
49
|
+
change.old_element,
|
|
50
|
+
change.new_element,
|
|
51
|
+
inline_changes: inline_changes
|
|
52
|
+
)
|
|
53
|
+
when "-"
|
|
54
|
+
old_line_number += 1
|
|
55
|
+
stats["deletions"] += 1
|
|
56
|
+
build_row("removed", old_line_number, nil, change.old_element, nil)
|
|
57
|
+
when "+"
|
|
58
|
+
new_line_number += 1
|
|
59
|
+
stats["additions"] += 1
|
|
60
|
+
build_row("added", nil, new_line_number, nil, change.new_element)
|
|
61
|
+
end
|
|
62
|
+
end.compact
|
|
63
|
+
|
|
64
|
+
{ "rows" => rows, "stats" => stats }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_row(kind, old_line, new_line, old_text, new_text, inline_changes: nil)
|
|
70
|
+
normalized_old = old_text.to_s
|
|
71
|
+
normalized_new = new_text.to_s
|
|
72
|
+
|
|
73
|
+
row = {
|
|
74
|
+
"kind" => kind,
|
|
75
|
+
"line_code" => Digest::SHA1.hexdigest(
|
|
76
|
+
[ kind, old_line, new_line, normalized_old, normalized_new ].join("\u0000")
|
|
77
|
+
),
|
|
78
|
+
"new_line" => new_line,
|
|
79
|
+
"new_text" => normalized_new,
|
|
80
|
+
"old_line" => old_line,
|
|
81
|
+
"old_text" => normalized_old
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
row["inline_changes"] = inline_changes if inline_changes_present?(inline_changes)
|
|
85
|
+
row
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def lines(content)
|
|
89
|
+
return [] if content.empty?
|
|
90
|
+
|
|
91
|
+
content.lines(chomp: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_inline_changes(old_text, new_text, changed_row_count:)
|
|
95
|
+
return unless Reviewkit::Diffs::IntralineBudget.allow?(
|
|
96
|
+
old_text: old_text,
|
|
97
|
+
new_text: new_text,
|
|
98
|
+
review_document_count: @review_document_count,
|
|
99
|
+
changed_row_count: changed_row_count
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
Reviewkit::Diffs::IntralineDiff.call(old_text:, new_text:)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def inline_changes_present?(inline_changes)
|
|
106
|
+
return false if inline_changes.blank?
|
|
107
|
+
|
|
108
|
+
inline_changes.values.any?(&:present?)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reviewkit
|
|
4
|
+
module Reviews
|
|
5
|
+
class Create
|
|
6
|
+
def self.call(...)
|
|
7
|
+
new(...).call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(title:, description: nil, creator: nil, reviewable: nil, external_reference: nil, metadata: {}, review_attributes: {}, status: "draft", documents: [])
|
|
11
|
+
@title = title
|
|
12
|
+
@description = description
|
|
13
|
+
@creator = creator
|
|
14
|
+
@reviewable = reviewable
|
|
15
|
+
@external_reference = external_reference
|
|
16
|
+
@metadata = metadata
|
|
17
|
+
@review_attributes = review_attributes
|
|
18
|
+
@status = status
|
|
19
|
+
@documents = documents
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
Reviewkit::Current.set(actor: @creator, source: self.class.name) do
|
|
24
|
+
Review.transaction do
|
|
25
|
+
review = Review.new(
|
|
26
|
+
{
|
|
27
|
+
title: @title,
|
|
28
|
+
description: @description,
|
|
29
|
+
status: @status,
|
|
30
|
+
external_reference: @external_reference,
|
|
31
|
+
metadata: @metadata,
|
|
32
|
+
reviewable: @reviewable
|
|
33
|
+
}.merge(@review_attributes)
|
|
34
|
+
)
|
|
35
|
+
review.creator = @creator
|
|
36
|
+
review.save!
|
|
37
|
+
|
|
38
|
+
@documents.each_with_index do |document, index|
|
|
39
|
+
payload = document.to_h.with_indifferent_access
|
|
40
|
+
review.documents.create!(
|
|
41
|
+
intraline_review_document_count: @documents.size,
|
|
42
|
+
path: payload.fetch(:path),
|
|
43
|
+
language: payload.fetch(:language, "plaintext"),
|
|
44
|
+
old_content: payload.fetch(:old_content, ""),
|
|
45
|
+
new_content: payload.fetch(:new_content, ""),
|
|
46
|
+
metadata: payload.fetch(:metadata, {}),
|
|
47
|
+
position: payload.fetch(:position, index)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
review
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Reviewkit</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
</head>
|
|
10
|
+
|
|
11
|
+
<body class="min-h-screen bg-slate-50 text-slate-950 antialiased">
|
|
12
|
+
<%= render "reviewkit/shared/flash" %>
|
|
13
|
+
<%= yield %>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% review_frame_id = local_assigns[:frame_id] || dom_id(comment.review_thread.review, :review) %>
|
|
2
|
+
|
|
3
|
+
<%= turbo_frame_tag dom_id(comment) do %>
|
|
4
|
+
<article class="reviewkit-comment-card">
|
|
5
|
+
<header class="reviewkit-comment-header">
|
|
6
|
+
<div class="reviewkit-comment-meta">
|
|
7
|
+
<span class="text-[13px] font-semibold text-slate-900">
|
|
8
|
+
<%= reviewkit_actor_label(comment.author) %>
|
|
9
|
+
</span>
|
|
10
|
+
|
|
11
|
+
<% if reviewkit_comment_edited?(comment) %>
|
|
12
|
+
<span class="reviewkit-comment-edited">edited</span>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div class="flex items-center gap-2">
|
|
17
|
+
<div class="text-[11px] text-slate-500">
|
|
18
|
+
<%= time_ago_in_words(comment.created_at) %> ago
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<% if reviewkit_comment_manageable?(comment) %>
|
|
22
|
+
<div class="reviewkit-inline-actions" aria-label="Comment actions">
|
|
23
|
+
<%= link_to edit_review_thread_comment_path(comment.review_thread, comment),
|
|
24
|
+
class: "reviewkit-inline-icon-button",
|
|
25
|
+
data: { turbo_frame: dom_id(comment) },
|
|
26
|
+
aria: { label: "Edit comment" },
|
|
27
|
+
title: "Edit comment" do %>
|
|
28
|
+
<%= reviewkit_pencil_icon %>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if reviewkit_comment_destroyable?(comment) %>
|
|
32
|
+
<%= link_to review_thread_comment_path(comment.review_thread, comment, frame_id: review_frame_id, view: local_assigns[:view_mode]),
|
|
33
|
+
class: "reviewkit-inline-icon-button reviewkit-inline-icon-button--danger",
|
|
34
|
+
data: {
|
|
35
|
+
turbo_method: :delete,
|
|
36
|
+
turbo_frame: review_frame_id,
|
|
37
|
+
turbo_confirm: "Delete this comment?"
|
|
38
|
+
},
|
|
39
|
+
aria: { label: "Delete comment" },
|
|
40
|
+
title: "Delete comment" do %>
|
|
41
|
+
<%= reviewkit_trash_icon %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
</div>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
</header>
|
|
48
|
+
|
|
49
|
+
<div class="mt-1.5 text-[13px] leading-5 text-slate-700">
|
|
50
|
+
<%= reviewkit_render_comment_body(comment) %>
|
|
51
|
+
</div>
|
|
52
|
+
</article>
|
|
53
|
+
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%= turbo_frame_tag dom_id(comment) do %>
|
|
2
|
+
<%= form_with model: [comment.review_thread, comment], class: "reviewkit-comment-edit-form" do |form| %>
|
|
3
|
+
<% if comment.errors.any? %>
|
|
4
|
+
<div class="reviewkit-comment-error">
|
|
5
|
+
<%= safe_join(comment.errors.full_messages.map { |error| content_tag(:div, error) }) %>
|
|
6
|
+
</div>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<label class="block">
|
|
10
|
+
<span class="mb-1 block text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Edit comment</span>
|
|
11
|
+
<%= form.text_area :body,
|
|
12
|
+
rows: 3,
|
|
13
|
+
class: "reviewkit-textarea",
|
|
14
|
+
autofocus: true %>
|
|
15
|
+
</label>
|
|
16
|
+
|
|
17
|
+
<div class="flex items-center justify-end gap-2">
|
|
18
|
+
<%= link_to "Cancel",
|
|
19
|
+
review_thread_comment_path(comment.review_thread, comment),
|
|
20
|
+
class: "reviewkit-button reviewkit-button--ghost reviewkit-button--small",
|
|
21
|
+
data: { turbo_frame: dom_id(comment) } %>
|
|
22
|
+
<%= form.submit "Save",
|
|
23
|
+
class: "reviewkit-button reviewkit-button--primary reviewkit-button--small" %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%= form_with model: [thread, Reviewkit::Comment.new], class: "space-y-2 border-t border-slate-200 bg-slate-50 px-3 py-2.5" do |form| %>
|
|
2
|
+
<%= form.hidden_field :frame_id, value: local_assigns[:frame_id] %>
|
|
3
|
+
<%= form.hidden_field :view, value: local_assigns[:view_mode] %>
|
|
4
|
+
<label class="block">
|
|
5
|
+
<span class="mb-1 block text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Reply</span>
|
|
6
|
+
<%= form.text_area :body,
|
|
7
|
+
rows: 2,
|
|
8
|
+
class: "reviewkit-textarea",
|
|
9
|
+
placeholder: "Add to the conversation..." %>
|
|
10
|
+
</label>
|
|
11
|
+
|
|
12
|
+
<div class="flex justify-end">
|
|
13
|
+
<%= form.submit "Reply",
|
|
14
|
+
class: "reviewkit-button reviewkit-button--secondary reviewkit-button--small" %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<div class="reviewkit-thread-stack">
|
|
2
|
+
<% threads.each do |thread| %>
|
|
3
|
+
<%= render "reviewkit/review_threads/thread",
|
|
4
|
+
expanded: composer_open,
|
|
5
|
+
frame_id: frame_id,
|
|
6
|
+
review: review,
|
|
7
|
+
thread: thread,
|
|
8
|
+
view_mode: view_mode %>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<div
|
|
12
|
+
class="<%= [("hidden" unless composer_open), "reviewkit-thread-composer"].compact.join(" ") %>"
|
|
13
|
+
>
|
|
14
|
+
<% if thread_errors.present? %>
|
|
15
|
+
<div class="mb-2 border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs text-rose-700">
|
|
16
|
+
<%= safe_join(thread_errors.map { |error| content_tag(:div, error) }) %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%= form_with url: review_review_threads_path(review), scope: :review_thread, class: "space-y-2" do |form| %>
|
|
21
|
+
<%= form.hidden_field :document_id, value: document.id %>
|
|
22
|
+
<%= form.hidden_field :frame_id, value: frame_id %>
|
|
23
|
+
<%= form.hidden_field :line_code, value: line_code %>
|
|
24
|
+
<%= form.hidden_field :view, value: view_mode %>
|
|
25
|
+
<%= form.hidden_field :side,
|
|
26
|
+
value: composer_side.presence || (row&.dig("new_line").present? ? "new" : "old") %>
|
|
27
|
+
<%= form.hidden_field :old_line, value: row&.dig("old_line") %>
|
|
28
|
+
<%= form.hidden_field :new_line, value: row&.dig("new_line") %>
|
|
29
|
+
<%= form.hidden_field :old_text, value: row&.dig("old_text") %>
|
|
30
|
+
<%= form.hidden_field :new_text, value: row&.dig("new_text") %>
|
|
31
|
+
|
|
32
|
+
<label class="block">
|
|
33
|
+
<span class="mb-1 block text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
|
34
|
+
Start a discussion on the <%= (composer_side.presence || (row&.dig("new_line").present? ? "new" : "old")) %> side
|
|
35
|
+
</span>
|
|
36
|
+
<%= form.text_area :body,
|
|
37
|
+
rows: 3,
|
|
38
|
+
class: "reviewkit-textarea",
|
|
39
|
+
placeholder: "Leave a review comment for this line...",
|
|
40
|
+
autofocus: composer_open %>
|
|
41
|
+
</label>
|
|
42
|
+
|
|
43
|
+
<div class="flex items-center justify-end gap-2">
|
|
44
|
+
<%= link_to "Cancel",
|
|
45
|
+
review_path(review, document_id: document.id, view: view_mode),
|
|
46
|
+
class: "reviewkit-button reviewkit-button--secondary reviewkit-button--small",
|
|
47
|
+
data: { turbo_frame: frame_id } %>
|
|
48
|
+
<%= form.submit "Comment",
|
|
49
|
+
class: "reviewkit-button reviewkit-button--primary reviewkit-button--small" %>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%= turbo_frame_tag reviewkit_thread_bucket_id(document, line_code) do %>
|
|
2
|
+
<%= render "reviewkit/review_threads/bucket",
|
|
3
|
+
composer_open: local_assigns.fetch(:composer_open, false),
|
|
4
|
+
composer_side: local_assigns[:composer_side],
|
|
5
|
+
document: document,
|
|
6
|
+
frame_id: local_assigns[:frame_id],
|
|
7
|
+
review: review,
|
|
8
|
+
row: row,
|
|
9
|
+
thread_errors: thread_errors,
|
|
10
|
+
threads: threads,
|
|
11
|
+
line_code: line_code,
|
|
12
|
+
view_mode: local_assigns[:view_mode] %>
|
|
13
|
+
<% end %>
|