plan_my_stuff 0.1.0 → 1.0.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -1,37 +1,51 @@
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
9
8
  # - `Comment.create!` / `Comment.list` return persisted instances
10
9
  # - `comment.save!` / `comment.update!` / `comment.reload` for persistence
11
10
  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
11
+ # @return [Integer, nil] GitHub comment ID
12
+ attribute :id, :big_integer
13
+ # @return [String, nil] full body as stored on GitHub
14
+ attribute :raw_body, :string
16
15
  # @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
16
+ attribute :metadata, default: -> { PlanMyStuff::CommentMetadata.new }
17
+ # @return [String, nil] comment body without the metadata HTML comment
18
+ attribute :body, :string
19
+ # @return [PlanMyStuff::Issue, nil] parent issue
20
+ attribute :issue
21
+ # @return [Time, nil] GitHub's updated_at timestamp
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
25
+ # @return [Symbol, nil] :public or :internal (locally set or from metadata)
26
+ attribute :visibility
27
+
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.
30
+ attr_accessor :waiting_on_reply
25
31
 
26
32
  class << self
27
33
  # Creates a comment on a GitHub issue with PMS metadata and a visible header.
28
34
  #
35
+ # @raise [PlanMyStuff::LockedIssueError] if the parent issue is locked
36
+ #
29
37
  # @param issue [PlanMyStuff::Issue] parent issue
30
38
  # @param body [String]
31
39
  # @param user [Object, Integer] user object or user_id
32
40
  # @param visibility [Symbol] :public or :internal
33
41
  # @param custom_fields [Hash]
34
42
  # @param issue_body [Boolean] whether this comment holds the issue body
43
+ # @param waiting_on_reply [Boolean] when true and the author is a support user, marks the issue as waiting on
44
+ # an end-user reply. Ignored for non-support authors.
45
+ # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the comment. Each entry
46
+ # may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
47
+ # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
48
+ # +PlanMyStuff::Attachment+ instance (passthrough, no re-upload).
35
49
  #
36
50
  # @return [PlanMyStuff::Comment]
37
51
  #
@@ -42,26 +56,47 @@ module PlanMyStuff
42
56
  visibility: :public,
43
57
  custom_fields: {},
44
58
  skip_responded: false,
45
- issue_body: false
59
+ issue_body: false,
60
+ waiting_on_reply: false,
61
+ attachments: []
46
62
  )
47
- resolved_user = UserResolver.resolve(user)
63
+ raise(PlanMyStuff::LockedIssueError, "Issue ##{issue.number} is locked") if issue.locked?
64
+
65
+ resolved_user = PlanMyStuff::UserResolver.resolve(user)
48
66
  visibility = resolve_visibility(visibility, resolved_user)
49
- comment_metadata = CommentMetadata.build(
67
+ comment_metadata = PlanMyStuff::CommentMetadata.build(
50
68
  user: resolved_user,
51
69
  visibility: visibility.to_s,
52
70
  custom_fields: custom_fields,
53
71
  issue_body: issue_body,
54
72
  )
73
+ comment_metadata.validate_custom_fields!
74
+ comment_metadata.attachments = PlanMyStuff::AttachmentUploader.upload_all!(
75
+ repo: issue.repo,
76
+ issue_number: issue.number,
77
+ files: attachments,
78
+ )
55
79
 
56
80
  header = build_header(resolved_user)
57
81
  full_body = "#{header}\n\n#{body}"
58
- serialized_body = MetadataParser.serialize(comment_metadata.to_h, full_body)
59
-
60
- result = PlanMyStuff.client.rest(:add_comment, issue.repo, issue.number, serialized_body)
82
+ serialized_body = PlanMyStuff::MetadataParser.serialize!(comment_metadata.to_h, full_body)
83
+
84
+ client = PlanMyStuff.client
85
+ result = client.rest(:add_comment, issue.repo, issue.number, serialized_body)
86
+ store_etag_to_cache(
87
+ client,
88
+ issue.repo,
89
+ read_field(result, :id),
90
+ result,
91
+ cache_writer: :write_comment,
92
+ )
61
93
 
62
- mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded
94
+ issue.mark_responded!(resolved_user) unless skip_responded
63
95
 
64
- build(result, issue: issue)
96
+ comment = build(result, issue: issue)
97
+ PlanMyStuff::Notifications.instrument('comment_created', comment, user: resolved_user)
98
+ apply_waiting_state_transitions!(issue, resolved_user, waiting_on_reply, comment)
99
+ comment
65
100
  end
66
101
 
67
102
  # Updates an existing GitHub comment body.
@@ -73,7 +108,16 @@ module PlanMyStuff
73
108
  # @return [Object] Octokit response
74
109
  #
75
110
  def update!(id:, repo:, body:)
76
- PlanMyStuff.client.rest(:update_comment, repo, id, body)
111
+ client = PlanMyStuff.client
112
+ result = client.rest(:update_comment, repo, id, body)
113
+ store_etag_to_cache(
114
+ client,
115
+ repo,
116
+ id,
117
+ result,
118
+ cache_writer: :write_comment,
119
+ )
120
+ result
77
121
  end
78
122
 
79
123
  # Finds a single comment by ID, given its parent issue.
@@ -84,7 +128,16 @@ module PlanMyStuff
84
128
  # @return [PlanMyStuff::Comment]
85
129
  #
86
130
  def find(id, issue:)
87
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
131
+ client = PlanMyStuff.client
132
+ github_comment =
133
+ fetch_with_etag_cache(
134
+ client,
135
+ issue.repo,
136
+ id,
137
+ rest_method: :issue_comment,
138
+ cache_reader: :read_comment,
139
+ cache_writer: :write_comment,
140
+ )
88
141
  build(github_comment, issue: issue)
89
142
  end
90
143
 
@@ -96,8 +149,21 @@ module PlanMyStuff
96
149
  # @return [Array<PlanMyStuff::Comment>]
97
150
  #
98
151
  def list(issue:, pms_only: false)
99
- github_comments = PlanMyStuff.client.rest(:issue_comments, issue.repo, issue.number)
100
- comments = github_comments.map { |gc| build(gc, issue: issue) }
152
+ client = PlanMyStuff.client
153
+ params = { issue_number: issue.number }
154
+
155
+ cached = PlanMyStuff::Cache.read_list(:comment, issue.repo, params)
156
+ request_options = cached ? { headers: { 'If-None-Match' => cached[:etag] } } : {}
157
+
158
+ github_comments = client.rest(:issue_comments, issue.repo, issue.number, **request_options)
159
+
160
+ comments =
161
+ if cached && not_modified?(client)
162
+ cached[:body].map { |gc| build(gc, issue: issue) }
163
+ else
164
+ store_list_etag_to_cache(client, :comment, issue.repo, params, github_comments)
165
+ github_comments.map { |gc| build(gc, issue: issue) }
166
+ end
101
167
 
102
168
  pms_only ? comments.select(&:pms_comment?) : comments
103
169
  end
@@ -126,7 +192,7 @@ module PlanMyStuff
126
192
  def build_header(user)
127
193
  display_name =
128
194
  if user.present?
129
- UserResolver.display_name(user)
195
+ PlanMyStuff::UserResolver.display_name(user)
130
196
  else
131
197
  'Unknown'
132
198
  end
@@ -147,88 +213,96 @@ module PlanMyStuff
147
213
 
148
214
  return :public if user.blank?
149
215
 
150
- return :public unless UserResolver.support?(user)
216
+ return :public unless PlanMyStuff::UserResolver.support?(user)
151
217
 
152
218
  :internal
153
219
  end
154
220
 
155
- # Sets responded_at on the issue metadata if this is the first support
156
- # comment and the issue hasn't been responded to yet.
221
+ # Mutates issue waiting state based on the comment's author. Support users with +waiting_on_reply: true+ enter
222
+ # the issue into waiting-on-user state. Non-support users clear any active waiting-on-user state and
223
+ # auto-reopen issues that were closed by the inactivity sweep.
157
224
  #
158
- # @param issue [PlanMyStuff::Issue] parent issue
159
- # @param user [Object, nil] resolved user object
225
+ # No-ops on non-PMS issues or when no user is resolved.
226
+ #
227
+ # @param issue [PlanMyStuff::Issue]
228
+ # @param user [Object, nil] resolved user
229
+ # @param waiting_on_reply [Boolean]
230
+ # @param comment [PlanMyStuff::Comment] the just-created comment
160
231
  #
161
232
  # @return [void]
162
233
  #
163
- def mark_issue_responded_if_first_support_comment(issue, user)
234
+ def apply_waiting_state_transitions!(issue, user, waiting_on_reply, comment)
164
235
  return if user.nil?
165
236
 
166
- return unless UserResolver.support?(user)
167
237
  return unless issue.pms_issue?
168
238
 
169
- return if issue.metadata.responded?
170
-
171
- Issue.update!(
172
- number: issue.number,
173
- repo: issue.repo,
174
- metadata: { responded_at: Time.now.utc.iso8601 },
175
- )
239
+ # Auto-reopen fires only for non-support replies; a support comment on a
240
+ # +closed_by_inactivity+ issue is treated as a closure note and requires
241
+ # the explicit Reopen button to bring the issue back.
242
+ if PlanMyStuff::UserResolver.support?(user)
243
+ issue.enter_waiting_on_user!(user: user) if waiting_on_reply
244
+ elsif issue.metadata.closed_by_inactivity
245
+ issue.reopen_by_reply!(comment: comment, user: user)
246
+ elsif issue.metadata.waiting_on_user_at.present?
247
+ issue.clear_waiting_on_user!
248
+ end
176
249
  end
177
250
  end
178
251
 
179
- def initialize(**attrs)
180
- @id = attrs.delete(:id)
181
- @raw_body = nil
182
- @metadata = CommentMetadata.new
183
- super
184
- end
185
-
186
252
  # Persists the comment. Creates if new, updates if persisted.
187
253
  #
188
- # @raise [PlanMyStuff::StaleObjectError] on update if stale
189
- #
190
254
  # @return [self]
191
255
  #
192
- def save!
256
+ def save!(user: nil)
193
257
  if new_record?
194
258
  created = self.class.create!(
195
259
  issue: issue,
196
260
  body: body,
261
+ user: user || metadata.created_by,
197
262
  visibility: visibility || :public,
263
+ custom_fields: metadata.custom_fields.to_h,
264
+ issue_body: metadata.issue_body,
265
+ waiting_on_reply: waiting_on_reply,
266
+ attachments: metadata.attachments,
198
267
  )
199
268
  hydrate_from_comment(created)
200
269
  else
201
- update!(body: body)
270
+ update_attrs = { user: user, body: body }
271
+ update_attrs[:visibility] = visibility if visibility_changed?
272
+ update!(**update_attrs)
202
273
  end
203
274
 
204
275
  self
205
276
  end
206
277
 
207
- # Updates this comment on GitHub. Raises StaleObjectError if the remote
208
- # has been modified since this instance was loaded.
278
+ # Updates this comment on GitHub. Raises StaleObjectError if the remote has been modified since this instance was
279
+ # loaded.
209
280
  #
210
281
  # @param attrs [Hash] attributes to update (body:, visibility:)
211
282
  #
212
- # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
213
- #
214
283
  # @return [self]
215
284
  #
216
- def update!(**attrs)
285
+ def update!(user: nil, **attrs)
217
286
  raise_if_stale!
218
287
 
288
+ captured_changes = capture_update_changes(attrs)
289
+
219
290
  new_body = attrs[:body] || body
291
+ new_body = preserve_header(new_body) if attrs.key?(:body)
220
292
  meta_hash = metadata.to_h
221
293
 
222
294
  if attrs.key?(:visibility)
223
- new_visibility = attrs[:visibility].to_s
295
+ resolved_user = PlanMyStuff::UserResolver.resolve(user)
296
+ new_visibility = self.class.__send__(:resolve_visibility, attrs[:visibility], resolved_user).to_s
224
297
  meta_hash[:visibility] = new_visibility
225
- meta_hash[:updated_at] = Time.now.utc.iso8601
226
298
  end
227
299
 
228
- serialized = MetadataParser.serialize(meta_hash, new_body)
300
+ serialized = PlanMyStuff::MetadataParser.serialize!(meta_hash, new_body)
229
301
  self.class.update!(id: id, repo: issue.repo, body: serialized)
230
302
 
231
303
  reload
304
+ PlanMyStuff::Notifications.instrument('comment_updated', self, user: user, changes: captured_changes)
305
+ self
232
306
  end
233
307
 
234
308
  # Re-fetches this comment from GitHub and updates all local attributes.
@@ -236,41 +310,58 @@ module PlanMyStuff
236
310
  # @return [self]
237
311
  #
238
312
  def reload
239
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
240
- hydrate_from_github(github_comment, issue: issue)
313
+ fresh = self.class.find(id, issue: issue)
314
+ hydrate_from_comment(fresh)
241
315
  self
242
316
  end
243
317
 
318
+ # GitHub web URL for this comment, for escape-hatch "View on GitHub" links.
319
+ #
320
+ # @return [String, nil]
321
+ #
322
+ def html_url
323
+ safe_read_field(github_response, :html_url)
324
+ end
325
+
326
+ # Serializes the comment to a JSON-safe hash, excluding the back-reference to the parent issue to prevent
327
+ # recursive serialization cycles.
328
+ #
329
+ # @return [Hash]
330
+ #
331
+ def as_json(options = {})
332
+ merged_except = Array.wrap(options[:except]) + ['issue']
333
+ super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
334
+ end
335
+
244
336
  # @return [Boolean]
245
337
  def pms_comment?
246
338
  metadata.schema_version.present?
247
339
  end
248
340
 
249
- # Returns the comment visibility as a symbol.
250
- # Uses the locally set value if present, otherwise falls back to metadata.
341
+ # Returns the comment visibility as a symbol. Uses the locally set value if present, otherwise falls back to
342
+ # metadata.
251
343
  #
252
344
  # @return [Symbol, nil] :public or :internal
253
345
  #
254
346
  def visibility
255
- @visibility || metadata.visibility&.to_sym
347
+ super || metadata.visibility&.to_sym
256
348
  end
257
349
 
258
- # Checks if the comment is visible to the given user.
259
- # Public PMS comments: visible to everyone the parent issue is visible to.
260
- # Internal PMS comments: visible only to support users.
261
- # Non-PMS comments: visible only to support users.
350
+ # Checks if the comment is visible to the given user. Public PMS comments: visible to everyone the parent issue
351
+ # is visible to. Internal PMS comments: visible only to support users. Non-PMS comments: visible only to support
352
+ # users.
262
353
  #
263
354
  # @param user [Object, Integer] user object or user_id
264
355
  #
265
356
  # @return [Boolean]
266
357
  #
267
358
  def visible_to?(user)
268
- resolved = PMS::UserResolver.resolve(user)
359
+ resolved = PlanMyStuff::UserResolver.resolve(user)
269
360
 
270
361
  if pms_comment?
271
- issue.visible_to?(resolved) && (visibility != :internal || PMS::UserResolver.support?(resolved))
362
+ issue.visible_to?(resolved) && (visibility != :internal || PlanMyStuff::UserResolver.support?(resolved))
272
363
  else
273
- PMS::UserResolver.support?(resolved)
364
+ PlanMyStuff::UserResolver.support?(resolved)
274
365
  end
275
366
  end
276
367
 
@@ -293,6 +384,39 @@ module PlanMyStuff
293
384
 
294
385
  private
295
386
 
387
+ # Prepends the existing header to new_body if the comment currently has one.
388
+ #
389
+ # @param new_body [String]
390
+ #
391
+ # @return [String]
392
+ #
393
+ def preserve_header(new_body)
394
+ existing_header = header
395
+ return new_body if existing_header.blank?
396
+
397
+ "#{existing_header}\n\n#{new_body}"
398
+ end
399
+
400
+ # Computes the +ActiveModel::Dirty+-style changes hash from the +update!+ +attrs+ hash vs the current in-memory
401
+ # state. Only includes keys whose value actually differs.
402
+ #
403
+ # @param attrs [Hash]
404
+ #
405
+ # @return [Hash{String => Array(Object, Object)}]
406
+ #
407
+ def capture_update_changes(attrs)
408
+ changes = {}
409
+ if attrs.key?(:body) && attrs[:body] != body
410
+ changes['body'] = [body, attrs[:body]]
411
+ end
412
+ if attrs.key?(:visibility)
413
+ new_visibility = attrs[:visibility].to_s
414
+ current_visibility = visibility&.to_s
415
+ changes['visibility'] = [current_visibility, new_visibility] if current_visibility != new_visibility
416
+ end
417
+ changes
418
+ end
419
+
296
420
  # Populates this instance from a GitHub API response.
297
421
  #
298
422
  # @param github_comment [Object] Octokit comment response
@@ -301,15 +425,18 @@ module PlanMyStuff
301
425
  # @return [void]
302
426
  #
303
427
  def hydrate_from_github(github_comment, issue:)
304
- @id = read_field(github_comment, :id)
305
- @raw_body = read_field(github_comment, :body)
306
- @issue = issue
307
-
308
- parsed = MetadataParser.parse(@raw_body)
309
- @metadata = CommentMetadata.from_hash(parsed[:metadata])
310
- @body = parsed[:body]
311
- @visibility = @metadata.visibility&.to_sym
312
- @persisted = true
428
+ @github_response = github_comment
429
+ self.id = read_field(github_comment, :id)
430
+ self.raw_body = read_field(github_comment, :body)
431
+ self.updated_at = parse_github_time(safe_read_field(github_comment, :updated_at))
432
+ self.created_at = parse_github_time(safe_read_field(github_comment, :created_at))
433
+ self.issue = issue
434
+
435
+ parsed = PlanMyStuff::MetadataParser.parse(raw_body)
436
+ self.metadata = PlanMyStuff::CommentMetadata.from_hash(parsed[:metadata])
437
+ self.body = parsed[:body]
438
+ self.visibility = metadata.visibility&.to_sym
439
+ persisted!
313
440
  end
314
441
 
315
442
  # Copies attributes from another Comment instance into self.
@@ -319,38 +446,36 @@ module PlanMyStuff
319
446
  # @return [void]
320
447
  #
321
448
  def hydrate_from_comment(other)
322
- @id = other.id
323
- @body = other.body
324
- @raw_body = other.raw_body
325
- @issue = other.issue
326
- @metadata = other.metadata
327
- @visibility = other.visibility
328
- @persisted = true
449
+ @github_response = other.github_response
450
+ self.id = other.id
451
+ self.body = other.body
452
+ self.raw_body = other.raw_body
453
+ self.updated_at = other.updated_at
454
+ self.created_at = other.created_at
455
+ self.issue = other.issue
456
+ self.metadata = other.metadata
457
+ self.visibility = other.visibility
458
+ persisted!
329
459
  end
330
460
 
331
- # Raises StaleObjectError if the remote comment has been modified
332
- # since this instance was loaded.
461
+ # Raises StaleObjectError if the remote comment has been modified since this instance was loaded.
333
462
  #
334
- # @raise [PlanMyStuff::StaleObjectError]
463
+ # @raise [PlanMyStuff::StaleObjectError] if comment was modified after loading
335
464
  #
336
465
  # @return [void]
337
466
  #
338
467
  def raise_if_stale!
339
468
  return if new_record?
340
- return if metadata.updated_at.nil?
469
+ return if updated_at.nil?
341
470
 
342
- github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
343
- parsed = MetadataParser.parse(
344
- github_comment.respond_to?(:body) ? github_comment.body : github_comment[:body],
345
- )
346
- remote_metadata = CommentMetadata.from_hash(parsed[:metadata])
347
- remote_time = remote_metadata.updated_at
348
- local_time = metadata.updated_at
471
+ remote = self.class.find(id, issue: issue)
472
+ remote_time = remote.updated_at
473
+ local_time = updated_at
349
474
 
350
475
  return if remote_time.nil?
351
476
  return if local_time && remote_time.to_i == local_time.to_i
352
477
 
353
- raise(StaleObjectError.new(
478
+ raise(PlanMyStuff::StaleObjectError.new(
354
479
  "Comment ##{id} has been modified remotely",
355
480
  local_updated_at: local_time,
356
481
  remote_updated_at: remote_time,
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class CommentMetadata < BaseMetadata
4
+ class CommentMetadata < PlanMyStuff::BaseMetadata
5
5
  # @return [Boolean] true if this comment holds the issue's body content
6
6
  attr_accessor :issue_body
7
+ # @return [Array<PlanMyStuff::Attachment>] consuming-app attachment records associated with this comment
8
+ attr_reader :attachments
7
9
 
8
10
  class << self
9
11
  # Builds a CommentMetadata from a parsed hash (e.g. from MetadataParser)
@@ -14,8 +16,9 @@ module PlanMyStuff
14
16
  #
15
17
  def from_hash(hash)
16
18
  metadata = new
17
- apply_common_from_hash(metadata, hash)
19
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:comment))
18
20
  metadata.issue_body = hash[:issue_body] || false
21
+ metadata.attachments = hash[:attachments]
19
22
 
20
23
  metadata
21
24
  end
@@ -26,13 +29,21 @@ module PlanMyStuff
26
29
  # @param visibility [String] "public" or "internal"
27
30
  # @param custom_fields [Hash] app-defined field values
28
31
  # @param issue_body [Boolean] whether this comment holds the issue body
32
+ # @param attachments [Array<Hash, PlanMyStuff::Attachment>] consuming-app attachment records
29
33
  #
30
34
  # @return [CommentMetadata]
31
35
  #
32
- def build(user:, visibility: 'internal', custom_fields: {}, issue_body: false)
36
+ def build(user:, visibility: 'internal', custom_fields: {}, issue_body: false, attachments: [])
33
37
  metadata = new
34
- apply_common_build(metadata, user: user, visibility: visibility, custom_fields_data: custom_fields)
38
+ apply_common_build(
39
+ metadata,
40
+ user: user,
41
+ visibility: visibility,
42
+ custom_fields_data: custom_fields,
43
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:comment),
44
+ )
35
45
  metadata.issue_body = issue_body
46
+ metadata.attachments = attachments
36
47
 
37
48
  metadata
38
49
  end
@@ -41,6 +52,18 @@ module PlanMyStuff
41
52
  def initialize
42
53
  super
43
54
  @issue_body = false
55
+ @attachments = []
56
+ end
57
+
58
+ # Assigns +attachments+, normalizing each entry through
59
+ # +PlanMyStuff::Attachment+ and dropping any malformed ones.
60
+ #
61
+ # @param raw [Array, nil]
62
+ #
63
+ # @return [Array<PlanMyStuff::Attachment>]
64
+ #
65
+ def attachments=(raw)
66
+ @attachments = normalize_attachments(raw)
44
67
  end
45
68
 
46
69
  # @return [Boolean]
@@ -50,7 +73,47 @@ module PlanMyStuff
50
73
 
51
74
  # @return [Hash]
52
75
  def to_h
53
- super.merge(issue_body: issue_body)
76
+ super.merge(
77
+ issue_body: issue_body,
78
+ attachments: attachments.map(&:to_h),
79
+ )
54
80
  end
81
+
82
+ private
83
+
84
+ # Builds a +PlanMyStuff::Attachment+ from each parsed entry.
85
+ # Accepts:
86
+ # - an existing +PlanMyStuff::Attachment+
87
+ # - the structured shape +{filename:, owner:, repo:, sha:, path:}+
88
+ #
89
+ # Malformed hash entries are silently dropped so a single bad
90
+ # historical entry doesn't crash +Comment.find+. An already-
91
+ # constructed +Attachment+ that fails validation raises so the
92
+ # caller is not silently missing an uploaded file.
93
+ #
94
+ # @param raw [Array, nil]
95
+ #
96
+ # @return [Array<PlanMyStuff::Attachment>]
97
+ #
98
+ def normalize_attachments(raw)
99
+ Array.wrap(raw).filter_map do |entry|
100
+ next if entry.nil?
101
+
102
+ attachment =
103
+ if entry.is_a?(PlanMyStuff::Attachment)
104
+ entry
105
+ elsif entry.respond_to?(:transform_keys)
106
+ PlanMyStuff::Attachment.new(entry.transform_keys(&:to_sym))
107
+ end
108
+ next if attachment.nil?
109
+
110
+ attachment.validate!
111
+ attachment
112
+ rescue ActiveModel::ValidationError, ActiveModel::UnknownAttributeError, ArgumentError
113
+ raise if entry.is_a?(PlanMyStuff::Attachment)
114
+
115
+ next
116
+ end
117
+ end
55
118
  end
56
119
  end