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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/CODE_OF_CONDUCT.md +123 -0
  4. data/CONTRIBUTING.md +44 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.md +335 -0
  7. data/Rakefile +7 -0
  8. data/SECURITY.md +18 -0
  9. data/app/assets/builds/reviewkit/application.css +2 -0
  10. data/app/assets/javascripts/reviewkit/application.js +12 -0
  11. data/app/assets/javascripts/reviewkit/controllers/file_nav_controller.js +24 -0
  12. data/app/assets/javascripts/reviewkit/controllers/review_index_controller.js +84 -0
  13. data/app/assets/tailwind/reviewkit/application.css +865 -0
  14. data/app/controllers/reviewkit/application_controller.rb +80 -0
  15. data/app/controllers/reviewkit/comments_controller.rb +147 -0
  16. data/app/controllers/reviewkit/review_threads_controller.rb +277 -0
  17. data/app/controllers/reviewkit/reviews_controller.rb +142 -0
  18. data/app/helpers/reviewkit/application_helper.rb +12 -0
  19. data/app/helpers/reviewkit/asset_helper.rb +39 -0
  20. data/app/helpers/reviewkit/diff_helper.rb +230 -0
  21. data/app/helpers/reviewkit/flash_helper.rb +36 -0
  22. data/app/helpers/reviewkit/frame_helper.rb +37 -0
  23. data/app/helpers/reviewkit/icon_helper.rb +107 -0
  24. data/app/helpers/reviewkit/review_thread_helper.rb +54 -0
  25. data/app/models/concerns/reviewkit/notifies_lifecycle_events.rb +39 -0
  26. data/app/models/reviewkit/application_record.rb +7 -0
  27. data/app/models/reviewkit/comment.rb +29 -0
  28. data/app/models/reviewkit/current.rb +7 -0
  29. data/app/models/reviewkit/document.rb +79 -0
  30. data/app/models/reviewkit/review.rb +66 -0
  31. data/app/models/reviewkit/review_thread.rb +75 -0
  32. data/app/services/reviewkit/diffs/intraline_budget.rb +40 -0
  33. data/app/services/reviewkit/diffs/intraline_diff.rb +220 -0
  34. data/app/services/reviewkit/diffs/split_diff.rb +112 -0
  35. data/app/services/reviewkit/reviews/create.rb +57 -0
  36. data/app/views/layouts/reviewkit/application.html.erb +15 -0
  37. data/app/views/reviewkit/comments/_comment.html.erb +53 -0
  38. data/app/views/reviewkit/comments/_edit_form.html.erb +26 -0
  39. data/app/views/reviewkit/comments/_form.html.erb +16 -0
  40. data/app/views/reviewkit/review_threads/_bucket.html.erb +53 -0
  41. data/app/views/reviewkit/review_threads/_bucket_frame.html.erb +13 -0
  42. data/app/views/reviewkit/review_threads/_bucket_row.html.erb +55 -0
  43. data/app/views/reviewkit/review_threads/_edit_form.html.erb +29 -0
  44. data/app/views/reviewkit/review_threads/_thread.html.erb +87 -0
  45. data/app/views/reviewkit/reviews/_document.html.erb +41 -0
  46. data/app/views/reviewkit/reviews/_document_split.html.erb +73 -0
  47. data/app/views/reviewkit/reviews/_document_unified.html.erb +57 -0
  48. data/app/views/reviewkit/reviews/_edit_form.html.erb +35 -0
  49. data/app/views/reviewkit/reviews/_index_content.html.erb +160 -0
  50. data/app/views/reviewkit/reviews/_review_sidebar.html.erb +70 -0
  51. data/app/views/reviewkit/reviews/_show_content.html.erb +164 -0
  52. data/app/views/reviewkit/reviews/index.html.erb +11 -0
  53. data/app/views/reviewkit/reviews/show.html.erb +11 -0
  54. data/app/views/reviewkit/shared/_flash.html.erb +10 -0
  55. data/bin/console +4 -0
  56. data/bin/lint +4 -0
  57. data/bin/rails +14 -0
  58. data/bin/setup +9 -0
  59. data/bin/test +4 -0
  60. data/config/importmap.rb +6 -0
  61. data/config/routes.rb +24 -0
  62. data/db/migrate/20260331181500_create_reviewkit_reviews.rb +19 -0
  63. data/db/migrate/20260331181600_create_reviewkit_documents.rb +23 -0
  64. data/db/migrate/20260331181700_create_reviewkit_review_threads.rb +23 -0
  65. data/db/migrate/20260331181800_create_reviewkit_comments.rb +15 -0
  66. data/db/migrate/20260401093000_add_description_to_reviewkit_reviews.rb +7 -0
  67. data/lib/generators/reviewkit/controllers/controllers_generator.rb +24 -0
  68. data/lib/generators/reviewkit/controllers/templates/comments_controller_extension.rb +13 -0
  69. data/lib/generators/reviewkit/controllers/templates/review_threads_controller_extension.rb +13 -0
  70. data/lib/generators/reviewkit/controllers/templates/reviews_controller_extension.rb +19 -0
  71. data/lib/generators/reviewkit/install/install_generator.rb +52 -0
  72. data/lib/generators/reviewkit/install/templates/importmap.rb +3 -0
  73. data/lib/generators/reviewkit/install/templates/reviewkit.rb +19 -0
  74. data/lib/generators/reviewkit/models/models_generator.rb +24 -0
  75. data/lib/generators/reviewkit/models/templates/comment_extension.rb +21 -0
  76. data/lib/generators/reviewkit/models/templates/review_extension.rb +22 -0
  77. data/lib/generators/reviewkit/models/templates/review_thread_extension.rb +21 -0
  78. data/lib/generators/reviewkit/views/views_generator.rb +15 -0
  79. data/lib/reviewkit/configuration.rb +33 -0
  80. data/lib/reviewkit/engine.rb +67 -0
  81. data/lib/reviewkit/version.rb +5 -0
  82. data/lib/reviewkit.rb +26 -0
  83. data/lib/tasks/reviewkit_tasks.rake +12 -0
  84. data/sig/reviewkit.rbs +129 -0
  85. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewkit
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewkit
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :actor, :controller, :source
6
+ end
7
+ 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