plan_my_stuff 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +100 -103
- data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
- data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
- data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
- data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
- data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
- data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
- data/app/jobs/plan_my_stuff/application_job.rb +2 -3
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
- data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
- data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
- data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
- data/config/routes.rb +2 -2
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +56 -3
- data/lib/plan_my_stuff/approval.rb +12 -4
- data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
- data/lib/plan_my_stuff/base_metadata.rb +4 -15
- data/lib/plan_my_stuff/base_project.rb +68 -55
- data/lib/plan_my_stuff/base_project_item.rb +61 -57
- data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
- data/lib/plan_my_stuff/client.rb +136 -48
- data/lib/plan_my_stuff/comment.rb +57 -57
- data/lib/plan_my_stuff/comment_metadata.rb +1 -1
- data/lib/plan_my_stuff/configuration.rb +95 -82
- data/lib/plan_my_stuff/errors.rb +10 -10
- data/lib/plan_my_stuff/graphql/queries.rb +1 -1
- data/lib/plan_my_stuff/issue.rb +501 -322
- data/lib/plan_my_stuff/issue_metadata.rb +10 -10
- data/lib/plan_my_stuff/label.rb +32 -16
- data/lib/plan_my_stuff/link.rb +15 -15
- data/lib/plan_my_stuff/markdown.rb +12 -6
- data/lib/plan_my_stuff/metadata_parser.rb +3 -1
- data/lib/plan_my_stuff/notifications.rb +1 -1
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
- data/lib/plan_my_stuff/pipeline.rb +61 -83
- data/lib/plan_my_stuff/project.rb +4 -4
- data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
- data/lib/plan_my_stuff/project_metadata.rb +1 -1
- data/lib/plan_my_stuff/reminders/closer.rb +1 -1
- data/lib/plan_my_stuff/reminders/fire.rb +3 -3
- data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
- data/lib/plan_my_stuff/repo.rb +12 -6
- data/lib/plan_my_stuff/test_helpers.rb +11 -11
- data/lib/plan_my_stuff/testing_project.rb +12 -11
- data/lib/plan_my_stuff/testing_project_item.rb +11 -9
- data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
- data/lib/plan_my_stuff.rb +26 -2
- data/lib/tasks/plan_my_stuff.rake +33 -20
- metadata +3 -2
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
# Wraps a GitHub comment with parsed PMS metadata.
|
|
5
|
-
# Class methods provide the public API for CRUD operations.
|
|
4
|
+
# Wraps a GitHub comment with parsed PMS metadata. Class methods provide the public API for CRUD operations.
|
|
6
5
|
#
|
|
7
6
|
# Follows an ActiveRecord-style pattern:
|
|
8
7
|
# - `Comment.new(**attrs)` creates an unpersisted instance
|
|
@@ -21,11 +20,13 @@ module PlanMyStuff
|
|
|
21
20
|
attribute :issue
|
|
22
21
|
# @return [Time, nil] GitHub's updated_at timestamp
|
|
23
22
|
attribute :updated_at
|
|
23
|
+
# @return [Time, nil] GitHub's created_at timestamp; settable on unpersisted comments for use with +Issue.import+
|
|
24
|
+
attribute :created_at
|
|
24
25
|
# @return [Symbol, nil] :public or :internal (locally set or from metadata)
|
|
25
26
|
attribute :visibility
|
|
26
27
|
|
|
27
|
-
# @return [Boolean, nil] transient flag used by the new-comment form to
|
|
28
|
-
#
|
|
28
|
+
# @return [Boolean, nil] transient flag used by the new-comment form to request waiting-on-user state when a
|
|
29
|
+
# support user posts. Not persisted.
|
|
29
30
|
attr_accessor :waiting_on_reply
|
|
30
31
|
|
|
31
32
|
class << self
|
|
@@ -37,9 +38,10 @@ module PlanMyStuff
|
|
|
37
38
|
# @param visibility [Symbol] :public or :internal
|
|
38
39
|
# @param custom_fields [Hash]
|
|
39
40
|
# @param issue_body [Boolean] whether this comment holds the issue body
|
|
40
|
-
# @param waiting_on_reply [Boolean] when true and the author is a
|
|
41
|
-
#
|
|
42
|
-
#
|
|
41
|
+
# @param waiting_on_reply [Boolean] when true and the author is a support user, marks the issue as waiting on
|
|
42
|
+
# an end-user reply. Ignored for non-support authors.
|
|
43
|
+
#
|
|
44
|
+
# @raise [PlanMyStuff::LockedIssueError] if the parent issue is locked
|
|
43
45
|
#
|
|
44
46
|
# @return [PlanMyStuff::Comment]
|
|
45
47
|
#
|
|
@@ -55,9 +57,9 @@ module PlanMyStuff
|
|
|
55
57
|
)
|
|
56
58
|
raise(PlanMyStuff::LockedIssueError, "Issue ##{issue.number} is locked") if issue.locked?
|
|
57
59
|
|
|
58
|
-
resolved_user = UserResolver.resolve(user)
|
|
60
|
+
resolved_user = PlanMyStuff::UserResolver.resolve(user)
|
|
59
61
|
visibility = resolve_visibility(visibility, resolved_user)
|
|
60
|
-
comment_metadata = CommentMetadata.build(
|
|
62
|
+
comment_metadata = PlanMyStuff::CommentMetadata.build(
|
|
61
63
|
user: resolved_user,
|
|
62
64
|
visibility: visibility.to_s,
|
|
63
65
|
custom_fields: custom_fields,
|
|
@@ -67,7 +69,7 @@ module PlanMyStuff
|
|
|
67
69
|
|
|
68
70
|
header = build_header(resolved_user)
|
|
69
71
|
full_body = "#{header}\n\n#{body}"
|
|
70
|
-
serialized_body = MetadataParser.serialize(comment_metadata.to_h, full_body)
|
|
72
|
+
serialized_body = PlanMyStuff::MetadataParser.serialize!(comment_metadata.to_h, full_body)
|
|
71
73
|
|
|
72
74
|
client = PlanMyStuff.client
|
|
73
75
|
result = client.rest(:add_comment, issue.repo, issue.number, serialized_body)
|
|
@@ -79,11 +81,11 @@ module PlanMyStuff
|
|
|
79
81
|
cache_writer: :write_comment,
|
|
80
82
|
)
|
|
81
83
|
|
|
82
|
-
mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded
|
|
84
|
+
mark_issue_responded_if_first_support_comment!(issue, resolved_user) unless skip_responded
|
|
83
85
|
|
|
84
86
|
comment = build(result, issue: issue)
|
|
85
87
|
PlanMyStuff::Notifications.instrument('comment.created', comment, user: resolved_user)
|
|
86
|
-
apply_waiting_state_transitions(issue, resolved_user, waiting_on_reply, comment)
|
|
88
|
+
apply_waiting_state_transitions!(issue, resolved_user, waiting_on_reply, comment)
|
|
87
89
|
comment
|
|
88
90
|
end
|
|
89
91
|
|
|
@@ -180,7 +182,7 @@ module PlanMyStuff
|
|
|
180
182
|
def build_header(user)
|
|
181
183
|
display_name =
|
|
182
184
|
if user.present?
|
|
183
|
-
UserResolver.display_name(user)
|
|
185
|
+
PlanMyStuff::UserResolver.display_name(user)
|
|
184
186
|
else
|
|
185
187
|
'Unknown'
|
|
186
188
|
end
|
|
@@ -201,39 +203,37 @@ module PlanMyStuff
|
|
|
201
203
|
|
|
202
204
|
return :public if user.blank?
|
|
203
205
|
|
|
204
|
-
return :public unless UserResolver.support?(user)
|
|
206
|
+
return :public unless PlanMyStuff::UserResolver.support?(user)
|
|
205
207
|
|
|
206
208
|
:internal
|
|
207
209
|
end
|
|
208
210
|
|
|
209
|
-
# Sets responded_at on the issue metadata if this is the first support
|
|
210
|
-
#
|
|
211
|
+
# Sets responded_at on the issue metadata if this is the first support comment and the issue hasn't been
|
|
212
|
+
# responded to yet.
|
|
211
213
|
#
|
|
212
214
|
# @param issue [PlanMyStuff::Issue] parent issue
|
|
213
215
|
# @param user [Object, nil] resolved user object
|
|
214
216
|
#
|
|
215
217
|
# @return [void]
|
|
216
218
|
#
|
|
217
|
-
def mark_issue_responded_if_first_support_comment(issue, user)
|
|
219
|
+
def mark_issue_responded_if_first_support_comment!(issue, user)
|
|
218
220
|
return if user.nil?
|
|
219
221
|
|
|
220
|
-
return unless UserResolver.support?(user)
|
|
222
|
+
return unless PlanMyStuff::UserResolver.support?(user)
|
|
221
223
|
return unless issue.pms_issue?
|
|
222
224
|
|
|
223
225
|
return if issue.metadata.responded?
|
|
224
226
|
|
|
225
|
-
Issue.update!(
|
|
227
|
+
PlanMyStuff::Issue.update!(
|
|
226
228
|
number: issue.number,
|
|
227
229
|
repo: issue.repo,
|
|
228
|
-
metadata: { responded_at: Time.now.utc
|
|
230
|
+
metadata: { responded_at: PlanMyStuff.format_time(Time.now.utc) },
|
|
229
231
|
)
|
|
230
232
|
end
|
|
231
233
|
|
|
232
|
-
# Mutates issue waiting state based on the comment's author.
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
# active waiting-on-user state and auto-reopen issues that
|
|
236
|
-
# were closed by the inactivity sweep.
|
|
234
|
+
# Mutates issue waiting state based on the comment's author. Support users with +waiting_on_reply: true+ enter
|
|
235
|
+
# the issue into waiting-on-user state. Non-support users clear any active waiting-on-user state and
|
|
236
|
+
# auto-reopen issues that were closed by the inactivity sweep.
|
|
237
237
|
#
|
|
238
238
|
# No-ops on non-PMS issues or when no user is resolved.
|
|
239
239
|
#
|
|
@@ -244,7 +244,7 @@ module PlanMyStuff
|
|
|
244
244
|
#
|
|
245
245
|
# @return [void]
|
|
246
246
|
#
|
|
247
|
-
def apply_waiting_state_transitions(issue, user, waiting_on_reply, comment)
|
|
247
|
+
def apply_waiting_state_transitions!(issue, user, waiting_on_reply, comment)
|
|
248
248
|
return if user.nil?
|
|
249
249
|
|
|
250
250
|
return unless issue.pms_issue?
|
|
@@ -252,7 +252,7 @@ module PlanMyStuff
|
|
|
252
252
|
# Auto-reopen fires only for non-support replies; a support comment on a
|
|
253
253
|
# +closed_by_inactivity+ issue is treated as a closure note and requires
|
|
254
254
|
# the explicit Reopen button to bring the issue back.
|
|
255
|
-
if UserResolver.support?(user)
|
|
255
|
+
if PlanMyStuff::UserResolver.support?(user)
|
|
256
256
|
issue.enter_waiting_on_user!(user: user) if waiting_on_reply
|
|
257
257
|
elsif issue.metadata.closed_by_inactivity
|
|
258
258
|
issue.reopen_by_reply!(comment: comment, user: user)
|
|
@@ -264,32 +264,32 @@ module PlanMyStuff
|
|
|
264
264
|
|
|
265
265
|
# Persists the comment. Creates if new, updates if persisted.
|
|
266
266
|
#
|
|
267
|
-
# @raise [PlanMyStuff::StaleObjectError] on update if stale
|
|
268
|
-
#
|
|
269
267
|
# @return [self]
|
|
270
268
|
#
|
|
271
|
-
def save!
|
|
269
|
+
def save!(user: nil)
|
|
272
270
|
if new_record?
|
|
273
271
|
created = self.class.create!(
|
|
274
272
|
issue: issue,
|
|
275
273
|
body: body,
|
|
274
|
+
user: user || metadata.created_by,
|
|
276
275
|
visibility: visibility || :public,
|
|
276
|
+
custom_fields: metadata.custom_fields.to_h,
|
|
277
|
+
issue_body: metadata.issue_body,
|
|
278
|
+
waiting_on_reply: waiting_on_reply,
|
|
277
279
|
)
|
|
278
280
|
hydrate_from_comment(created)
|
|
279
281
|
else
|
|
280
|
-
update!(body: body)
|
|
282
|
+
update!(user: user, body: body)
|
|
281
283
|
end
|
|
282
284
|
|
|
283
285
|
self
|
|
284
286
|
end
|
|
285
287
|
|
|
286
|
-
# Updates this comment on GitHub. Raises StaleObjectError if the remote
|
|
287
|
-
#
|
|
288
|
+
# Updates this comment on GitHub. Raises StaleObjectError if the remote has been modified since this instance was
|
|
289
|
+
# loaded.
|
|
288
290
|
#
|
|
289
291
|
# @param attrs [Hash] attributes to update (body:, visibility:)
|
|
290
292
|
#
|
|
291
|
-
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
292
|
-
#
|
|
293
293
|
# @return [self]
|
|
294
294
|
#
|
|
295
295
|
def update!(user: nil, **attrs)
|
|
@@ -302,11 +302,12 @@ module PlanMyStuff
|
|
|
302
302
|
meta_hash = metadata.to_h
|
|
303
303
|
|
|
304
304
|
if attrs.key?(:visibility)
|
|
305
|
-
|
|
305
|
+
resolved_user = PlanMyStuff::UserResolver.resolve(user)
|
|
306
|
+
new_visibility = self.class.__send__(:resolve_visibility, attrs[:visibility], resolved_user).to_s
|
|
306
307
|
meta_hash[:visibility] = new_visibility
|
|
307
308
|
end
|
|
308
309
|
|
|
309
|
-
serialized = MetadataParser.serialize(meta_hash, new_body)
|
|
310
|
+
serialized = PlanMyStuff::MetadataParser.serialize!(meta_hash, new_body)
|
|
310
311
|
self.class.update!(id: id, repo: issue.repo, body: serialized)
|
|
311
312
|
|
|
312
313
|
reload
|
|
@@ -332,8 +333,8 @@ module PlanMyStuff
|
|
|
332
333
|
safe_read_field(github_response, :html_url)
|
|
333
334
|
end
|
|
334
335
|
|
|
335
|
-
# Serializes the comment to a JSON-safe hash, excluding the back-reference
|
|
336
|
-
#
|
|
336
|
+
# Serializes the comment to a JSON-safe hash, excluding the back-reference to the parent issue to prevent
|
|
337
|
+
# recursive serialization cycles.
|
|
337
338
|
#
|
|
338
339
|
# @return [Hash]
|
|
339
340
|
#
|
|
@@ -347,8 +348,8 @@ module PlanMyStuff
|
|
|
347
348
|
metadata.schema_version.present?
|
|
348
349
|
end
|
|
349
350
|
|
|
350
|
-
# Returns the comment visibility as a symbol.
|
|
351
|
-
#
|
|
351
|
+
# Returns the comment visibility as a symbol. Uses the locally set value if present, otherwise falls back to
|
|
352
|
+
# metadata.
|
|
352
353
|
#
|
|
353
354
|
# @return [Symbol, nil] :public or :internal
|
|
354
355
|
#
|
|
@@ -356,22 +357,21 @@ module PlanMyStuff
|
|
|
356
357
|
super || metadata.visibility&.to_sym
|
|
357
358
|
end
|
|
358
359
|
|
|
359
|
-
# Checks if the comment is visible to the given user.
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
# Non-PMS comments: visible only to support users.
|
|
360
|
+
# Checks if the comment is visible to the given user. Public PMS comments: visible to everyone the parent issue
|
|
361
|
+
# is visible to. Internal PMS comments: visible only to support users. Non-PMS comments: visible only to support
|
|
362
|
+
# users.
|
|
363
363
|
#
|
|
364
364
|
# @param user [Object, Integer] user object or user_id
|
|
365
365
|
#
|
|
366
366
|
# @return [Boolean]
|
|
367
367
|
#
|
|
368
368
|
def visible_to?(user)
|
|
369
|
-
resolved =
|
|
369
|
+
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
370
370
|
|
|
371
371
|
if pms_comment?
|
|
372
|
-
issue.visible_to?(resolved) && (visibility != :internal ||
|
|
372
|
+
issue.visible_to?(resolved) && (visibility != :internal || PlanMyStuff::UserResolver.support?(resolved))
|
|
373
373
|
else
|
|
374
|
-
|
|
374
|
+
PlanMyStuff::UserResolver.support?(resolved)
|
|
375
375
|
end
|
|
376
376
|
end
|
|
377
377
|
|
|
@@ -407,9 +407,8 @@ module PlanMyStuff
|
|
|
407
407
|
"#{existing_header}\n\n#{new_body}"
|
|
408
408
|
end
|
|
409
409
|
|
|
410
|
-
# Computes the +ActiveModel::Dirty+-style changes hash from the +update!+
|
|
411
|
-
#
|
|
412
|
-
# value actually differs.
|
|
410
|
+
# Computes the +ActiveModel::Dirty+-style changes hash from the +update!+ +attrs+ hash vs the current in-memory
|
|
411
|
+
# state. Only includes keys whose value actually differs.
|
|
413
412
|
#
|
|
414
413
|
# @param attrs [Hash]
|
|
415
414
|
#
|
|
@@ -440,10 +439,11 @@ module PlanMyStuff
|
|
|
440
439
|
self.id = read_field(github_comment, :id)
|
|
441
440
|
self.raw_body = read_field(github_comment, :body)
|
|
442
441
|
self.updated_at = parse_github_time(safe_read_field(github_comment, :updated_at))
|
|
442
|
+
self.created_at = parse_github_time(safe_read_field(github_comment, :created_at))
|
|
443
443
|
self.issue = issue
|
|
444
444
|
|
|
445
|
-
parsed = MetadataParser.parse(raw_body)
|
|
446
|
-
self.metadata = CommentMetadata.from_hash(parsed[:metadata])
|
|
445
|
+
parsed = PlanMyStuff::MetadataParser.parse(raw_body)
|
|
446
|
+
self.metadata = PlanMyStuff::CommentMetadata.from_hash(parsed[:metadata])
|
|
447
447
|
self.body = parsed[:body]
|
|
448
448
|
self.visibility = metadata.visibility&.to_sym
|
|
449
449
|
persisted!
|
|
@@ -461,16 +461,16 @@ module PlanMyStuff
|
|
|
461
461
|
self.body = other.body
|
|
462
462
|
self.raw_body = other.raw_body
|
|
463
463
|
self.updated_at = other.updated_at
|
|
464
|
+
self.created_at = other.created_at
|
|
464
465
|
self.issue = other.issue
|
|
465
466
|
self.metadata = other.metadata
|
|
466
467
|
self.visibility = other.visibility
|
|
467
468
|
persisted!
|
|
468
469
|
end
|
|
469
470
|
|
|
470
|
-
# Raises StaleObjectError if the remote comment has been modified
|
|
471
|
-
# since this instance was loaded.
|
|
471
|
+
# Raises StaleObjectError if the remote comment has been modified since this instance was loaded.
|
|
472
472
|
#
|
|
473
|
-
# @raise [PlanMyStuff::StaleObjectError]
|
|
473
|
+
# @raise [PlanMyStuff::StaleObjectError] if comment was modified after loading
|
|
474
474
|
#
|
|
475
475
|
# @return [void]
|
|
476
476
|
#
|
|
@@ -485,7 +485,7 @@ module PlanMyStuff
|
|
|
485
485
|
return if remote_time.nil?
|
|
486
486
|
return if local_time && remote_time.to_i == local_time.to_i
|
|
487
487
|
|
|
488
|
-
raise(StaleObjectError.new(
|
|
488
|
+
raise(PlanMyStuff::StaleObjectError.new(
|
|
489
489
|
"Comment ##{id} has been modified remotely",
|
|
490
490
|
local_updated_at: local_time,
|
|
491
491
|
remote_updated_at: remote_time,
|