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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +595 -0
- data/CONFIGURATION.md +487 -0
- data/README.md +612 -88
- data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
- data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
- data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
- data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
- data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
- data/app/jobs/plan_my_stuff/application_job.rb +8 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
- data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
- data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +43 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
- data/lib/plan_my_stuff/application_record.rb +158 -1
- data/lib/plan_my_stuff/approval.rb +88 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +12 -0
- data/lib/plan_my_stuff/attachment.rb +83 -0
- data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
- data/lib/plan_my_stuff/base_metadata.rb +25 -28
- data/lib/plan_my_stuff/base_project.rb +502 -0
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
- data/lib/plan_my_stuff/base_project_item.rb +588 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +139 -64
- data/lib/plan_my_stuff/comment.rb +225 -100
- data/lib/plan_my_stuff/comment_metadata.rb +68 -5
- data/lib/plan_my_stuff/configuration.rb +459 -28
- data/lib/plan_my_stuff/custom_fields.rb +96 -12
- data/lib/plan_my_stuff/engine.rb +14 -2
- data/lib/plan_my_stuff/errors.rb +65 -5
- data/lib/plan_my_stuff/graphql/queries.rb +454 -0
- data/lib/plan_my_stuff/issue.rb +1097 -166
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
- data/lib/plan_my_stuff/issue_field.rb +126 -0
- data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
- data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
- data/lib/plan_my_stuff/issue_metadata.rb +132 -21
- data/lib/plan_my_stuff/label.rb +100 -13
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/markdown.rb +13 -7
- data/lib/plan_my_stuff/metadata_parser.rb +51 -12
- data/lib/plan_my_stuff/notifications.rb +148 -0
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +40 -0
- data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
- data/lib/plan_my_stuff/pipeline.rb +310 -0
- data/lib/plan_my_stuff/project.rb +63 -465
- data/lib/plan_my_stuff/project_item.rb +3 -409
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +47 -0
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +12 -0
- data/lib/plan_my_stuff/repo.rb +145 -0
- data/lib/plan_my_stuff/test_helpers.rb +265 -25
- data/lib/plan_my_stuff/testing_project.rb +292 -0
- data/lib/plan_my_stuff/testing_project_item.rb +218 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +24 -3
- data/lib/plan_my_stuff/verifier.rb +10 -0
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
- data/lib/plan_my_stuff.rb +55 -20
- data/lib/tasks/plan_my_stuff.rake +331 -0
- 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
|
-
|
|
14
|
-
# @return [String] full body as stored on GitHub
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
#
|
|
156
|
-
#
|
|
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
|
-
#
|
|
159
|
-
#
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
347
|
+
super || metadata.visibility&.to_sym
|
|
256
348
|
end
|
|
257
349
|
|
|
258
|
-
# Checks if the comment is visible to the given user.
|
|
259
|
-
#
|
|
260
|
-
#
|
|
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 =
|
|
359
|
+
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
269
360
|
|
|
270
361
|
if pms_comment?
|
|
271
|
-
issue.visible_to?(resolved) && (visibility != :internal ||
|
|
362
|
+
issue.visible_to?(resolved) && (visibility != :internal || PlanMyStuff::UserResolver.support?(resolved))
|
|
272
363
|
else
|
|
273
|
-
|
|
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
|
-
@
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
@
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
469
|
+
return if updated_at.nil?
|
|
341
470
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
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(
|
|
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
|