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.
- 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 +65 -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/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
- data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +11 -4
- 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 +144 -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_metadata.rb +0 -11
- 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 +174 -54
- data/lib/plan_my_stuff/configuration.rb +254 -8
- 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 +1477 -174
- 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 +62 -468
- 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 +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 +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 +16 -0
- data/lib/tasks/plan_my_stuff.rake +163 -0
- 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
|
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,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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
@
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
479
|
+
return if updated_at.nil?
|
|
356
480
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|