plan_my_stuff 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +569 -38
- data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
- data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
- data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
- data/app/jobs/plan_my_stuff/application_job.rb +9 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
- data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +38 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
- data/lib/plan_my_stuff/application_record.rb +121 -0
- data/lib/plan_my_stuff/approval.rb +80 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +14 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
- data/lib/plan_my_stuff/base_project.rb +661 -0
- data/lib/plan_my_stuff/base_project_item.rb +562 -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 +7 -0
- data/lib/plan_my_stuff/comment.rb +171 -50
- data/lib/plan_my_stuff/configuration.rb +210 -10
- data/lib/plan_my_stuff/custom_fields.rb +31 -17
- data/lib/plan_my_stuff/engine.rb +0 -4
- data/lib/plan_my_stuff/errors.rb +49 -0
- data/lib/plan_my_stuff/graphql/queries.rb +392 -0
- data/lib/plan_my_stuff/issue.rb +1476 -175
- data/lib/plan_my_stuff/issue_metadata.rb +122 -0
- data/lib/plan_my_stuff/label.rb +82 -11
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/notifications.rb +142 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +44 -0
- data/lib/plan_my_stuff/pipeline.rb +293 -0
- data/lib/plan_my_stuff/project.rb +30 -693
- data/lib/plan_my_stuff/project_item.rb +3 -417
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +9 -3
- 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 +16 -0
- data/lib/plan_my_stuff/test_helpers.rb +260 -15
- data/lib/plan_my_stuff/testing_project.rb +291 -0
- data/lib/plan_my_stuff/testing_project_item.rb +184 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +8 -3
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
- data/lib/plan_my_stuff.rb +15 -0
- data/lib/tasks/plan_my_stuff.rake +163 -0
- metadata +50 -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
|
data/lib/plan_my_stuff/client.rb
CHANGED
|
@@ -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
|
-
|
|
14
|
-
# @return [String] full body as stored on GitHub
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
# @
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
@
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
361
|
-
remote_time =
|
|
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?
|