plan_my_stuff 0.3.0 → 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +172 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +216 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +179 -0
  83. metadata +77 -3
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # ETag-backed HTTP cache for GitHub REST reads.
5
+ #
6
+ # Stores `{ etag:, body: }` pairs in `Rails.cache` keyed by resource.
7
+ # Readers look up the entry, pass its ETag in `If-None-Match`, and
8
+ # either (a) get a cheap 304 and serve the cached body, or (b) get a
9
+ # fresh 200, write the new ETag + body, and return that. Writes from
10
+ # +.create!+/+.update!+ front-load the cache with the fresh ETag
11
+ # returned on the mutating response so the next read is cheap too.
12
+ #
13
+ # Covers issues, comments, and their list endpoints.
14
+ #
15
+ module Cache
16
+ # Gem-internal cache version. Bump when the cache layout or the
17
+ # payload shape changes in a backwards-incompatible way so existing
18
+ # entries are orphaned rather than mis-read.
19
+ CACHE_VERSION = 'v1'
20
+
21
+ class << self
22
+ # @return [Boolean] whether `Rails.cache` is available and caching is enabled
23
+ def enabled?
24
+ return false unless PlanMyStuff.configuration.cache_enabled
25
+ return false if !defined?(::Rails) || !::Rails.respond_to?(:cache) || ::Rails.cache.nil?
26
+
27
+ true
28
+ end
29
+
30
+ # Reads the cached `{etag:, body:}` entry for an issue.
31
+ #
32
+ # @param repo [String] resolved full repo name (e.g. "Org/Name")
33
+ # @param number [Integer]
34
+ #
35
+ # @return [Hash{Symbol => Object}, nil]
36
+ #
37
+ def read_issue(repo, number)
38
+ return unless enabled?
39
+
40
+ ::Rails.cache.read(issue_key(repo, number))
41
+ end
42
+
43
+ # Writes a cache entry for an issue.
44
+ #
45
+ # @param repo [String] resolved full repo name
46
+ # @param number [Integer]
47
+ # @param etag [String, nil] value of the `ETag` response header
48
+ # @param body [Object] parsed GitHub issue response
49
+ #
50
+ # @return [void]
51
+ #
52
+ def write_issue(repo, number, etag:, body:)
53
+ return unless enabled?
54
+ return if etag.blank?
55
+
56
+ ::Rails.cache.write(
57
+ issue_key(repo, number),
58
+ { etag: etag, body: normalize(body) },
59
+ )
60
+ end
61
+
62
+ # Removes the cache entry for an issue.
63
+ #
64
+ # @param repo [String]
65
+ # @param number [Integer]
66
+ #
67
+ # @return [void]
68
+ #
69
+ def delete_issue(repo, number)
70
+ return unless enabled?
71
+
72
+ ::Rails.cache.delete(issue_key(repo, number))
73
+ end
74
+
75
+ # Reads the cached `{etag:, body:}` entry for a comment.
76
+ #
77
+ # @param repo [String] resolved full repo name (e.g. "Org/Name")
78
+ # @param id [Integer]
79
+ #
80
+ # @return [Hash{Symbol => Object}, nil]
81
+ #
82
+ def read_comment(repo, id)
83
+ return unless enabled?
84
+
85
+ ::Rails.cache.read(comment_key(repo, id))
86
+ end
87
+
88
+ # Writes a cache entry for a comment.
89
+ #
90
+ # @param repo [String] resolved full repo name
91
+ # @param id [Integer]
92
+ # @param etag [String, nil] value of the `ETag` response header
93
+ # @param body [Object] parsed GitHub comment response
94
+ #
95
+ # @return [void]
96
+ #
97
+ def write_comment(repo, id, etag:, body:)
98
+ return unless enabled?
99
+ return if etag.blank?
100
+
101
+ ::Rails.cache.write(
102
+ comment_key(repo, id),
103
+ { etag: etag, body: normalize(body) },
104
+ )
105
+ end
106
+
107
+ # Removes the cache entry for a comment.
108
+ #
109
+ # @param repo [String]
110
+ # @param id [Integer]
111
+ #
112
+ # @return [void]
113
+ #
114
+ def delete_comment(repo, id)
115
+ return unless enabled?
116
+
117
+ ::Rails.cache.delete(comment_key(repo, id))
118
+ end
119
+
120
+ # Reads the cached `{etag:, body:}` entry for a list endpoint.
121
+ #
122
+ # @param resource [Symbol] :issue or :comment
123
+ # @param repo [String] resolved full repo name
124
+ # @param params [Hash] query params that make this list unique
125
+ #
126
+ # @return [Hash{Symbol => Object}, nil]
127
+ #
128
+ def read_list(resource, repo, params)
129
+ return unless enabled?
130
+
131
+ ::Rails.cache.read(list_key(resource, repo, params))
132
+ end
133
+
134
+ # Writes a cache entry for a list endpoint.
135
+ #
136
+ # @param resource [Symbol] :issue or :comment
137
+ # @param repo [String] resolved full repo name
138
+ # @param params [Hash] query params that make this list unique
139
+ # @param etag [String, nil] value of the `ETag` response header
140
+ # @param body [Object] parsed GitHub list response (array of resources)
141
+ #
142
+ # @return [void]
143
+ #
144
+ def write_list(resource, repo, params, etag:, body:)
145
+ return unless enabled?
146
+ return if etag.blank?
147
+
148
+ ::Rails.cache.write(
149
+ list_key(resource, repo, params),
150
+ { etag: etag, body: normalize(body) },
151
+ )
152
+ end
153
+
154
+ private
155
+
156
+ # @return [String]
157
+ def issue_key(repo, number)
158
+ [key_prefix, 'issue', repo, number].join('/')
159
+ end
160
+
161
+ # @return [String]
162
+ def comment_key(repo, id)
163
+ [key_prefix, 'comment', repo, id].join('/')
164
+ end
165
+
166
+ # @return [String]
167
+ def list_key(resource, repo, params)
168
+ serialized = params.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}:#{v}" }.join(',')
169
+ [key_prefix, 'list', resource, repo, serialized].join('/')
170
+ end
171
+
172
+ # Builds the cache key prefix from gem + app cache versions so
173
+ # that bumping either side orphans all existing entries.
174
+ #
175
+ # @return [String]
176
+ #
177
+ def key_prefix
178
+ app_version = PlanMyStuff.configuration.cache_version || '0'
179
+ ['pms', CACHE_VERSION, app_version].join('/')
180
+ end
181
+
182
+ # Coerces a Sawyer::Resource, Struct, Array, or similar to a
183
+ # plain Hash (or Array of Hashes) so the cache payload is
184
+ # portable across stores. Anything else is passed through.
185
+ #
186
+ # @return [Object]
187
+ #
188
+ def normalize(body)
189
+ return body.map { |item| normalize(item) } if body.is_a?(Array)
190
+ return body.to_hash if body.respond_to?(:to_hash)
191
+ return body.to_h if body.respond_to?(:to_h)
192
+
193
+ body
194
+ end
195
+ end
196
+ end
197
+ end
@@ -11,6 +11,13 @@ module PlanMyStuff
11
11
  # @return [Octokit::Client]
12
12
  attr_reader :octokit
13
13
 
14
+ # Returns the Faraday response from the most recent Octokit call.
15
+ # Useful for reading headers (e.g. `ETag`, `X-RateLimit-Remaining`)
16
+ # that aren't included in the parsed response body.
17
+ #
18
+ # @return [Faraday::Response, nil]
19
+ delegate :last_response, to: :octokit
20
+
14
21
  # @return [Client]
15
22
  def initialize
16
23
  PlanMyStuff.configuration.validate!
@@ -9,21 +9,24 @@ module PlanMyStuff
9
9
  # - `Comment.create!` / `Comment.list` return persisted instances
10
10
  # - `comment.save!` / `comment.update!` / `comment.reload` for persistence
11
11
  class Comment < PlanMyStuff::ApplicationRecord
12
- # @return [Integer] GitHub comment ID
13
- attr_reader :id
14
- # @return [String] full body as stored on GitHub
15
- attr_reader :raw_body
12
+ # @return [Integer, nil] GitHub comment ID
13
+ attribute :id, :big_integer
14
+ # @return [String, nil] full body as stored on GitHub
15
+ attribute :raw_body, :string
16
16
  # @return [PlanMyStuff::CommentMetadata] parsed metadata (empty when no PMS metadata present)
17
- attr_reader :metadata
18
-
19
- # @return [String] comment body without the metadata HTML comment
20
- attr_accessor :body
21
- # @return [PlanMyStuff::Issue] parent issue
22
- attr_accessor :issue
17
+ attribute :metadata, default: -> { PlanMyStuff::CommentMetadata.new }
18
+ # @return [String, nil] comment body without the metadata HTML comment
19
+ attribute :body, :string
20
+ # @return [PlanMyStuff::Issue, nil] parent issue
21
+ attribute :issue
23
22
  # @return [Time, nil] GitHub's updated_at timestamp
24
- attr_reader :updated_at
25
- # @param value [Symbol, String, nil]
26
- attr_writer :visibility
23
+ attribute :updated_at
24
+ # @return [Symbol, nil] :public or :internal (locally set or from metadata)
25
+ attribute :visibility
26
+
27
+ # @return [Boolean, nil] transient flag used by the new-comment form to
28
+ # request waiting-on-user state when a support user posts. Not persisted.
29
+ attr_accessor :waiting_on_reply
27
30
 
28
31
  class << self
29
32
  # Creates a comment on a GitHub issue with PMS metadata and a visible header.
@@ -34,6 +37,9 @@ module PlanMyStuff
34
37
  # @param visibility [Symbol] :public or :internal
35
38
  # @param custom_fields [Hash]
36
39
  # @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
+ # support user, marks the issue as waiting on an end-user reply.
42
+ # Ignored for non-support authors.
37
43
  #
38
44
  # @return [PlanMyStuff::Comment]
39
45
  #
@@ -44,8 +50,11 @@ module PlanMyStuff
44
50
  visibility: :public,
45
51
  custom_fields: {},
46
52
  skip_responded: false,
47
- issue_body: false
53
+ issue_body: false,
54
+ waiting_on_reply: false
48
55
  )
56
+ raise(PlanMyStuff::LockedIssueError, "Issue ##{issue.number} is locked") if issue.locked?
57
+
49
58
  resolved_user = UserResolver.resolve(user)
50
59
  visibility = resolve_visibility(visibility, resolved_user)
51
60
  comment_metadata = CommentMetadata.build(
@@ -60,11 +69,22 @@ module PlanMyStuff
60
69
  full_body = "#{header}\n\n#{body}"
61
70
  serialized_body = MetadataParser.serialize(comment_metadata.to_h, full_body)
62
71
 
63
- result = PlanMyStuff.client.rest(:add_comment, issue.repo, issue.number, serialized_body)
72
+ client = PlanMyStuff.client
73
+ result = client.rest(:add_comment, issue.repo, issue.number, serialized_body)
74
+ store_etag_to_cache(
75
+ client,
76
+ issue.repo,
77
+ read_field(result, :id),
78
+ result,
79
+ cache_writer: :write_comment,
80
+ )
64
81
 
65
82
  mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded
66
83
 
67
- build(result, issue: issue)
84
+ comment = build(result, issue: issue)
85
+ PlanMyStuff::Notifications.instrument('comment.created', comment, user: resolved_user)
86
+ apply_waiting_state_transitions(issue, resolved_user, waiting_on_reply, comment)
87
+ comment
68
88
  end
69
89
 
70
90
  # Updates an existing GitHub comment body.
@@ -76,7 +96,16 @@ module PlanMyStuff
76
96
  # @return [Object] Octokit response
77
97
  #
78
98
  def update!(id:, repo:, body:)
79
- PlanMyStuff.client.rest(:update_comment, repo, id, body)
99
+ client = PlanMyStuff.client
100
+ result = client.rest(:update_comment, repo, id, body)
101
+ store_etag_to_cache(
102
+ client,
103
+ repo,
104
+ id,
105
+ result,
106
+ cache_writer: :write_comment,
107
+ )
108
+ result
80
109
  end
81
110
 
82
111
  # Finds a single comment by ID, given its parent issue.
@@ -87,7 +116,16 @@ module PlanMyStuff
87
116
  # @return [PlanMyStuff::Comment]
88
117
  #
89
118
  def find(id, issue:)
90
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
119
+ client = PlanMyStuff.client
120
+ github_comment =
121
+ fetch_with_etag_cache(
122
+ client,
123
+ issue.repo,
124
+ id,
125
+ rest_method: :issue_comment,
126
+ cache_reader: :read_comment,
127
+ cache_writer: :write_comment,
128
+ )
91
129
  build(github_comment, issue: issue)
92
130
  end
93
131
 
@@ -99,8 +137,21 @@ module PlanMyStuff
99
137
  # @return [Array<PlanMyStuff::Comment>]
100
138
  #
101
139
  def list(issue:, pms_only: false)
102
- github_comments = PlanMyStuff.client.rest(:issue_comments, issue.repo, issue.number)
103
- comments = github_comments.map { |gc| build(gc, issue: issue) }
140
+ client = PlanMyStuff.client
141
+ params = { issue_number: issue.number }
142
+
143
+ cached = PlanMyStuff::Cache.read_list(:comment, issue.repo, params)
144
+ request_options = cached ? { headers: { 'If-None-Match' => cached[:etag] } } : {}
145
+
146
+ github_comments = client.rest(:issue_comments, issue.repo, issue.number, **request_options)
147
+
148
+ comments =
149
+ if cached && not_modified?(client)
150
+ cached[:body].map { |gc| build(gc, issue: issue) }
151
+ else
152
+ store_list_etag_to_cache(client, :comment, issue.repo, params, github_comments)
153
+ github_comments.map { |gc| build(gc, issue: issue) }
154
+ end
104
155
 
105
156
  pms_only ? comments.select(&:pms_comment?) : comments
106
157
  end
@@ -177,13 +228,38 @@ module PlanMyStuff
177
228
  metadata: { responded_at: Time.now.utc.iso8601 },
178
229
  )
179
230
  end
180
- end
181
231
 
182
- def initialize(**attrs)
183
- @id = attrs.delete(:id)
184
- @raw_body = nil
185
- @metadata = CommentMetadata.new
186
- super
232
+ # Mutates issue waiting state based on the comment's author.
233
+ # Support users with +waiting_on_reply: true+ enter the issue
234
+ # into waiting-on-user state. Non-support users clear any
235
+ # active waiting-on-user state and auto-reopen issues that
236
+ # were closed by the inactivity sweep.
237
+ #
238
+ # No-ops on non-PMS issues or when no user is resolved.
239
+ #
240
+ # @param issue [PlanMyStuff::Issue]
241
+ # @param user [Object, nil] resolved user
242
+ # @param waiting_on_reply [Boolean]
243
+ # @param comment [PlanMyStuff::Comment] the just-created comment
244
+ #
245
+ # @return [void]
246
+ #
247
+ def apply_waiting_state_transitions(issue, user, waiting_on_reply, comment)
248
+ return if user.nil?
249
+
250
+ return unless issue.pms_issue?
251
+
252
+ # Auto-reopen fires only for non-support replies; a support comment on a
253
+ # +closed_by_inactivity+ issue is treated as a closure note and requires
254
+ # the explicit Reopen button to bring the issue back.
255
+ if UserResolver.support?(user)
256
+ issue.enter_waiting_on_user!(user: user) if waiting_on_reply
257
+ elsif issue.metadata.closed_by_inactivity
258
+ issue.reopen_by_reply!(comment: comment, user: user)
259
+ elsif issue.metadata.waiting_on_user_at.present?
260
+ issue.clear_waiting_on_user!
261
+ end
262
+ end
187
263
  end
188
264
 
189
265
  # Persists the comment. Creates if new, updates if persisted.
@@ -216,9 +292,11 @@ module PlanMyStuff
216
292
  #
217
293
  # @return [self]
218
294
  #
219
- def update!(**attrs)
295
+ def update!(user: nil, **attrs)
220
296
  raise_if_stale!
221
297
 
298
+ captured_changes = capture_update_changes(attrs)
299
+
222
300
  new_body = attrs[:body] || body
223
301
  new_body = preserve_header(new_body) if attrs.key?(:body)
224
302
  meta_hash = metadata.to_h
@@ -232,6 +310,8 @@ module PlanMyStuff
232
310
  self.class.update!(id: id, repo: issue.repo, body: serialized)
233
311
 
234
312
  reload
313
+ PlanMyStuff::Notifications.instrument('comment.updated', self, user: user, changes: captured_changes)
314
+ self
235
315
  end
236
316
 
237
317
  # Re-fetches this comment from GitHub and updates all local attributes.
@@ -239,11 +319,29 @@ module PlanMyStuff
239
319
  # @return [self]
240
320
  #
241
321
  def reload
242
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
243
- hydrate_from_github(github_comment, issue: issue)
322
+ fresh = self.class.find(id, issue: issue)
323
+ hydrate_from_comment(fresh)
244
324
  self
245
325
  end
246
326
 
327
+ # GitHub web URL for this comment, for escape-hatch "View on GitHub" links.
328
+ #
329
+ # @return [String, nil]
330
+ #
331
+ def html_url
332
+ safe_read_field(github_response, :html_url)
333
+ end
334
+
335
+ # Serializes the comment to a JSON-safe hash, excluding the back-reference
336
+ # to the parent issue to prevent recursive serialization cycles.
337
+ #
338
+ # @return [Hash]
339
+ #
340
+ def as_json(options = {})
341
+ merged_except = Array.wrap(options[:except]) + ['issue']
342
+ super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
343
+ end
344
+
247
345
  # @return [Boolean]
248
346
  def pms_comment?
249
347
  metadata.schema_version.present?
@@ -255,7 +353,7 @@ module PlanMyStuff
255
353
  # @return [Symbol, nil] :public or :internal
256
354
  #
257
355
  def visibility
258
- @visibility || metadata.visibility&.to_sym
356
+ super || metadata.visibility&.to_sym
259
357
  end
260
358
 
261
359
  # Checks if the comment is visible to the given user.
@@ -309,6 +407,27 @@ module PlanMyStuff
309
407
  "#{existing_header}\n\n#{new_body}"
310
408
  end
311
409
 
410
+ # Computes the +ActiveModel::Dirty+-style changes hash from the +update!+
411
+ # +attrs+ hash vs the current in-memory state. Only includes keys whose
412
+ # value actually differs.
413
+ #
414
+ # @param attrs [Hash]
415
+ #
416
+ # @return [Hash{String => Array(Object, Object)}]
417
+ #
418
+ def capture_update_changes(attrs)
419
+ changes = {}
420
+ if attrs.key?(:body) && attrs[:body] != body
421
+ changes['body'] = [body, attrs[:body]]
422
+ end
423
+ if attrs.key?(:visibility)
424
+ new_visibility = attrs[:visibility].to_s
425
+ current_visibility = visibility&.to_s
426
+ changes['visibility'] = [current_visibility, new_visibility] if current_visibility != new_visibility
427
+ end
428
+ changes
429
+ end
430
+
312
431
  # Populates this instance from a GitHub API response.
313
432
  #
314
433
  # @param github_comment [Object] Octokit comment response
@@ -317,16 +436,17 @@ module PlanMyStuff
317
436
  # @return [void]
318
437
  #
319
438
  def hydrate_from_github(github_comment, issue:)
320
- @id = read_field(github_comment, :id)
321
- @raw_body = read_field(github_comment, :body)
322
- @updated_at = parse_github_time(safe_read_field(github_comment, :updated_at))
323
- @issue = issue
324
-
325
- parsed = MetadataParser.parse(@raw_body)
326
- @metadata = CommentMetadata.from_hash(parsed[:metadata])
327
- @body = parsed[:body]
328
- @visibility = @metadata.visibility&.to_sym
329
- @persisted = true
439
+ @github_response = github_comment
440
+ self.id = read_field(github_comment, :id)
441
+ self.raw_body = read_field(github_comment, :body)
442
+ self.updated_at = parse_github_time(safe_read_field(github_comment, :updated_at))
443
+ self.issue = issue
444
+
445
+ parsed = MetadataParser.parse(raw_body)
446
+ self.metadata = CommentMetadata.from_hash(parsed[:metadata])
447
+ self.body = parsed[:body]
448
+ self.visibility = metadata.visibility&.to_sym
449
+ persisted!
330
450
  end
331
451
 
332
452
  # Copies attributes from another Comment instance into self.
@@ -336,14 +456,15 @@ module PlanMyStuff
336
456
  # @return [void]
337
457
  #
338
458
  def hydrate_from_comment(other)
339
- @id = other.id
340
- @body = other.body
341
- @raw_body = other.raw_body
342
- @updated_at = other.updated_at
343
- @issue = other.issue
344
- @metadata = other.metadata
345
- @visibility = other.visibility
346
- @persisted = true
459
+ @github_response = other.github_response
460
+ self.id = other.id
461
+ self.body = other.body
462
+ self.raw_body = other.raw_body
463
+ self.updated_at = other.updated_at
464
+ self.issue = other.issue
465
+ self.metadata = other.metadata
466
+ self.visibility = other.visibility
467
+ persisted!
347
468
  end
348
469
 
349
470
  # Raises StaleObjectError if the remote comment has been modified
@@ -357,8 +478,8 @@ module PlanMyStuff
357
478
  return if new_record?
358
479
  return if updated_at.nil?
359
480
 
360
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
361
- remote_time = parse_github_time(safe_read_field(github_comment, :updated_at))
481
+ remote = self.class.find(id, issue: issue)
482
+ remote_time = remote.updated_at
362
483
  local_time = updated_at
363
484
 
364
485
  return if remote_time.nil?