plan_my_stuff 0.2.0 → 0.4.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -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 +65 -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/edit.html.erb +7 -0
  31. data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
  32. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  33. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  34. data/app/views/plan_my_stuff/projects/show.html.erb +11 -4
  35. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  36. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  37. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  38. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  39. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  40. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  41. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  42. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  43. data/config/routes.rb +38 -15
  44. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  45. data/lib/plan_my_stuff/application_record.rb +144 -0
  46. data/lib/plan_my_stuff/approval.rb +80 -0
  47. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  48. data/lib/plan_my_stuff/archive.rb +14 -0
  49. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  50. data/lib/plan_my_stuff/base_metadata.rb +0 -11
  51. data/lib/plan_my_stuff/base_project.rb +661 -0
  52. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  53. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  54. data/lib/plan_my_stuff/cache.rb +197 -0
  55. data/lib/plan_my_stuff/client.rb +7 -0
  56. data/lib/plan_my_stuff/comment.rb +174 -54
  57. data/lib/plan_my_stuff/configuration.rb +254 -8
  58. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  59. data/lib/plan_my_stuff/engine.rb +0 -4
  60. data/lib/plan_my_stuff/errors.rb +49 -0
  61. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  62. data/lib/plan_my_stuff/issue.rb +1477 -174
  63. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  64. data/lib/plan_my_stuff/label.rb +82 -11
  65. data/lib/plan_my_stuff/link.rb +144 -0
  66. data/lib/plan_my_stuff/notifications.rb +142 -0
  67. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  68. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  69. data/lib/plan_my_stuff/pipeline.rb +293 -0
  70. data/lib/plan_my_stuff/project.rb +62 -468
  71. data/lib/plan_my_stuff/project_item.rb +3 -417
  72. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  73. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  74. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  75. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  76. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  77. data/lib/plan_my_stuff/reminders.rb +16 -0
  78. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  79. data/lib/plan_my_stuff/testing_project.rb +291 -0
  80. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  81. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  82. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  83. data/lib/plan_my_stuff/version.rb +1 -1
  84. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  85. data/lib/plan_my_stuff.rb +16 -0
  86. data/lib/tasks/plan_my_stuff.rake +163 -0
  87. metadata +54 -2
@@ -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,19 +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
23
- # @param value [Symbol, String, nil]
24
- attr_writer :visibility
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
22
+ # @return [Time, nil] GitHub's updated_at timestamp
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
25
30
 
26
31
  class << self
27
32
  # Creates a comment on a GitHub issue with PMS metadata and a visible header.
@@ -32,6 +37,9 @@ module PlanMyStuff
32
37
  # @param visibility [Symbol] :public or :internal
33
38
  # @param custom_fields [Hash]
34
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.
35
43
  #
36
44
  # @return [PlanMyStuff::Comment]
37
45
  #
@@ -42,8 +50,11 @@ module PlanMyStuff
42
50
  visibility: :public,
43
51
  custom_fields: {},
44
52
  skip_responded: false,
45
- issue_body: false
53
+ issue_body: false,
54
+ waiting_on_reply: false
46
55
  )
56
+ raise(PlanMyStuff::LockedIssueError, "Issue ##{issue.number} is locked") if issue.locked?
57
+
47
58
  resolved_user = UserResolver.resolve(user)
48
59
  visibility = resolve_visibility(visibility, resolved_user)
49
60
  comment_metadata = CommentMetadata.build(
@@ -58,11 +69,22 @@ module PlanMyStuff
58
69
  full_body = "#{header}\n\n#{body}"
59
70
  serialized_body = MetadataParser.serialize(comment_metadata.to_h, full_body)
60
71
 
61
- 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
+ )
62
81
 
63
82
  mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded
64
83
 
65
- 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
66
88
  end
67
89
 
68
90
  # Updates an existing GitHub comment body.
@@ -74,7 +96,16 @@ module PlanMyStuff
74
96
  # @return [Object] Octokit response
75
97
  #
76
98
  def update!(id:, repo:, body:)
77
- 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
78
109
  end
79
110
 
80
111
  # Finds a single comment by ID, given its parent issue.
@@ -85,7 +116,16 @@ module PlanMyStuff
85
116
  # @return [PlanMyStuff::Comment]
86
117
  #
87
118
  def find(id, issue:)
88
- 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
+ )
89
129
  build(github_comment, issue: issue)
90
130
  end
91
131
 
@@ -97,8 +137,21 @@ module PlanMyStuff
97
137
  # @return [Array<PlanMyStuff::Comment>]
98
138
  #
99
139
  def list(issue:, pms_only: false)
100
- github_comments = PlanMyStuff.client.rest(:issue_comments, issue.repo, issue.number)
101
- 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
102
155
 
103
156
  pms_only ? comments.select(&:pms_comment?) : comments
104
157
  end
@@ -175,13 +228,38 @@ module PlanMyStuff
175
228
  metadata: { responded_at: Time.now.utc.iso8601 },
176
229
  )
177
230
  end
178
- end
179
231
 
180
- def initialize(**attrs)
181
- @id = attrs.delete(:id)
182
- @raw_body = nil
183
- @metadata = CommentMetadata.new
184
- 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
185
263
  end
186
264
 
187
265
  # Persists the comment. Creates if new, updates if persisted.
@@ -214,9 +292,11 @@ module PlanMyStuff
214
292
  #
215
293
  # @return [self]
216
294
  #
217
- def update!(**attrs)
295
+ def update!(user: nil, **attrs)
218
296
  raise_if_stale!
219
297
 
298
+ captured_changes = capture_update_changes(attrs)
299
+
220
300
  new_body = attrs[:body] || body
221
301
  new_body = preserve_header(new_body) if attrs.key?(:body)
222
302
  meta_hash = metadata.to_h
@@ -224,13 +304,14 @@ module PlanMyStuff
224
304
  if attrs.key?(:visibility)
225
305
  new_visibility = attrs[:visibility].to_s
226
306
  meta_hash[:visibility] = new_visibility
227
- meta_hash[:updated_at] = Time.now.utc.iso8601
228
307
  end
229
308
 
230
309
  serialized = MetadataParser.serialize(meta_hash, new_body)
231
310
  self.class.update!(id: id, repo: issue.repo, body: serialized)
232
311
 
233
312
  reload
313
+ PlanMyStuff::Notifications.instrument('comment.updated', self, user: user, changes: captured_changes)
314
+ self
234
315
  end
235
316
 
236
317
  # Re-fetches this comment from GitHub and updates all local attributes.
@@ -238,11 +319,29 @@ module PlanMyStuff
238
319
  # @return [self]
239
320
  #
240
321
  def reload
241
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
242
- hydrate_from_github(github_comment, issue: issue)
322
+ fresh = self.class.find(id, issue: issue)
323
+ hydrate_from_comment(fresh)
243
324
  self
244
325
  end
245
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
+
246
345
  # @return [Boolean]
247
346
  def pms_comment?
248
347
  metadata.schema_version.present?
@@ -254,7 +353,7 @@ module PlanMyStuff
254
353
  # @return [Symbol, nil] :public or :internal
255
354
  #
256
355
  def visibility
257
- @visibility || metadata.visibility&.to_sym
356
+ super || metadata.visibility&.to_sym
258
357
  end
259
358
 
260
359
  # Checks if the comment is visible to the given user.
@@ -308,6 +407,27 @@ module PlanMyStuff
308
407
  "#{existing_header}\n\n#{new_body}"
309
408
  end
310
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
+
311
431
  # Populates this instance from a GitHub API response.
312
432
  #
313
433
  # @param github_comment [Object] Octokit comment response
@@ -316,15 +436,17 @@ module PlanMyStuff
316
436
  # @return [void]
317
437
  #
318
438
  def hydrate_from_github(github_comment, issue:)
319
- @id = read_field(github_comment, :id)
320
- @raw_body = read_field(github_comment, :body)
321
- @issue = issue
322
-
323
- parsed = MetadataParser.parse(@raw_body)
324
- @metadata = CommentMetadata.from_hash(parsed[:metadata])
325
- @body = parsed[:body]
326
- @visibility = @metadata.visibility&.to_sym
327
- @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!
328
450
  end
329
451
 
330
452
  # Copies attributes from another Comment instance into self.
@@ -334,13 +456,15 @@ module PlanMyStuff
334
456
  # @return [void]
335
457
  #
336
458
  def hydrate_from_comment(other)
337
- @id = other.id
338
- @body = other.body
339
- @raw_body = other.raw_body
340
- @issue = other.issue
341
- @metadata = other.metadata
342
- @visibility = other.visibility
343
- @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!
344
468
  end
345
469
 
346
470
  # Raises StaleObjectError if the remote comment has been modified
@@ -352,15 +476,11 @@ module PlanMyStuff
352
476
  #
353
477
  def raise_if_stale!
354
478
  return if new_record?
355
- return if metadata.updated_at.nil?
479
+ return if updated_at.nil?
356
480
 
357
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
358
- parsed = MetadataParser.parse(
359
- github_comment.respond_to?(:body) ? github_comment.body : github_comment[:body],
360
- )
361
- remote_metadata = CommentMetadata.from_hash(parsed[:metadata])
362
- remote_time = remote_metadata.updated_at
363
- local_time = metadata.updated_at
481
+ remote = self.class.find(id, issue: issue)
482
+ remote_time = remote.updated_at
483
+ local_time = updated_at
364
484
 
365
485
  return if remote_time.nil?
366
486
  return if local_time && remote_time.to_i == local_time.to_i