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
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -9,25 +9,30 @@ module PlanMyStuff
|
|
|
9
9
|
# - `Issue.create!` / `Issue.find` / `Issue.list` return persisted instances
|
|
10
10
|
# - `issue.save!` / `issue.update!` / `issue.reload` for persistence
|
|
11
11
|
class Issue < PlanMyStuff::ApplicationRecord
|
|
12
|
-
# @return [Integer] GitHub issue number
|
|
13
|
-
|
|
14
|
-
# @return [String] full body as stored on GitHub
|
|
15
|
-
|
|
12
|
+
# @return [Integer, nil] GitHub issue number
|
|
13
|
+
attribute :number, :integer
|
|
14
|
+
# @return [String, nil] full body as stored on GitHub
|
|
15
|
+
attribute :raw_body, :string
|
|
16
16
|
# @return [PlanMyStuff::IssueMetadata] parsed metadata (empty when no PMS metadata present)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
attr_writer :body
|
|
23
|
-
# @return [String] issue state ("open" or "closed")
|
|
24
|
-
attr_accessor :state
|
|
17
|
+
attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new }
|
|
18
|
+
# @return [String, nil] issue title
|
|
19
|
+
attribute :title, :string
|
|
20
|
+
# @return [String, nil] issue state ("open" or "closed")
|
|
21
|
+
attribute :state, :string
|
|
25
22
|
# @return [Array<String>] label names
|
|
26
|
-
|
|
23
|
+
attribute :labels, default: -> { [] }
|
|
27
24
|
# @return [Time, nil] GitHub's updated_at timestamp
|
|
28
|
-
|
|
25
|
+
attribute :updated_at
|
|
26
|
+
# @return [Time, nil] GitHub's closed_at timestamp (nil while open)
|
|
27
|
+
attribute :closed_at
|
|
28
|
+
# @return [Boolean] GitHub's +locked+ flag; +true+ for archived or
|
|
29
|
+
# manually-locked issues (no new comments)
|
|
30
|
+
attribute :locked, :boolean, default: false
|
|
31
|
+
alias locked? locked
|
|
29
32
|
# @return [PlanMyStuff::Repo, nil]
|
|
30
|
-
|
|
33
|
+
attribute :repo
|
|
34
|
+
# @return [String, nil] issue body (user-visible content, separate from metadata)
|
|
35
|
+
attribute :body, :string
|
|
31
36
|
|
|
32
37
|
class << self
|
|
33
38
|
# Creates a GitHub issue with PMS metadata embedded in the body.
|
|
@@ -39,6 +44,7 @@ module PlanMyStuff
|
|
|
39
44
|
# @param user [Object, Integer] user object or user_id
|
|
40
45
|
# @param metadata [Hash] custom fields hash
|
|
41
46
|
# @param add_to_project [Boolean, Integer, nil]
|
|
47
|
+
# @param visibility [String] "public" or "internal"
|
|
42
48
|
# @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
|
|
43
49
|
#
|
|
44
50
|
# @return [PlanMyStuff::Issue]
|
|
@@ -51,6 +57,7 @@ module PlanMyStuff
|
|
|
51
57
|
user: nil,
|
|
52
58
|
metadata: {},
|
|
53
59
|
add_to_project: nil,
|
|
60
|
+
visibility: 'public',
|
|
54
61
|
visibility_allowlist: []
|
|
55
62
|
)
|
|
56
63
|
raise(ValidationError.new('body must be present', field: :body, expected_type: :string)) if body.blank?
|
|
@@ -60,6 +67,7 @@ module PlanMyStuff
|
|
|
60
67
|
|
|
61
68
|
issue_metadata = IssueMetadata.build(
|
|
62
69
|
user: user,
|
|
70
|
+
visibility: visibility,
|
|
63
71
|
custom_fields: metadata,
|
|
64
72
|
)
|
|
65
73
|
issue_metadata.visibility_allowlist = Array.wrap(visibility_allowlist)
|
|
@@ -72,6 +80,7 @@ module PlanMyStuff
|
|
|
72
80
|
|
|
73
81
|
result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
|
|
74
82
|
number = read_field(result, :number)
|
|
83
|
+
store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
|
|
75
84
|
|
|
76
85
|
issue = find(number, repo: resolved_repo)
|
|
77
86
|
|
|
@@ -89,22 +98,43 @@ module PlanMyStuff
|
|
|
89
98
|
issue_body: true,
|
|
90
99
|
)
|
|
91
100
|
|
|
101
|
+
issue.reload
|
|
102
|
+
PlanMyStuff::Notifications.instrument('issue.created', issue, user: user)
|
|
92
103
|
issue
|
|
93
104
|
end
|
|
94
105
|
|
|
95
106
|
# Updates an existing GitHub issue.
|
|
96
107
|
#
|
|
108
|
+
# +metadata:+ accepts either:
|
|
109
|
+
# - a +PlanMyStuff::IssueMetadata+ instance - treated as the
|
|
110
|
+
# full authoritative metadata and serialized as-is (used by
|
|
111
|
+
# instance +save!+/+update!+ so local +@metadata+ mutations
|
|
112
|
+
# like +metadata.commit_sha = ...+ actually persist).
|
|
113
|
+
# - a +Hash+ - patch-style merge against the CURRENT remote
|
|
114
|
+
# metadata. Top-level keys are merged in; +:custom_fields+
|
|
115
|
+
# is merged separately so unrelated fields stay intact.
|
|
116
|
+
#
|
|
97
117
|
# @param number [Integer]
|
|
98
118
|
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
99
119
|
# @param title [String, nil]
|
|
100
120
|
# @param body [String, nil]
|
|
101
|
-
# @param metadata [Hash, nil]
|
|
121
|
+
# @param metadata [PlanMyStuff::IssueMetadata, Hash, nil]
|
|
102
122
|
# @param labels [Array<String>, nil]
|
|
103
123
|
# @param state [Symbol, nil] :open or :closed
|
|
124
|
+
# @param assignees [Array<String>, String, nil] GitHub logins
|
|
104
125
|
#
|
|
105
126
|
# @return [Object]
|
|
106
127
|
#
|
|
107
|
-
def update!(
|
|
128
|
+
def update!(
|
|
129
|
+
number:,
|
|
130
|
+
repo: nil,
|
|
131
|
+
title: nil,
|
|
132
|
+
body: nil,
|
|
133
|
+
metadata: nil,
|
|
134
|
+
labels: nil,
|
|
135
|
+
state: nil,
|
|
136
|
+
assignees: nil
|
|
137
|
+
)
|
|
108
138
|
client = PlanMyStuff.client
|
|
109
139
|
resolved_repo = client.resolve_repo(repo)
|
|
110
140
|
|
|
@@ -114,7 +144,11 @@ module PlanMyStuff
|
|
|
114
144
|
options[:state] = state.to_s unless state.nil?
|
|
115
145
|
options[:assignees] = Array.wrap(assignees) unless assignees.nil?
|
|
116
146
|
|
|
117
|
-
|
|
147
|
+
case metadata
|
|
148
|
+
when PlanMyStuff::IssueMetadata
|
|
149
|
+
metadata.validate_custom_fields!
|
|
150
|
+
options[:body] = MetadataParser.serialize(metadata.to_h, '')
|
|
151
|
+
when Hash
|
|
118
152
|
current = client.rest(:issue, resolved_repo, number)
|
|
119
153
|
current_body = current.respond_to?(:body) ? current.body : current[:body]
|
|
120
154
|
parsed = MetadataParser.parse(current_body)
|
|
@@ -133,7 +167,11 @@ module PlanMyStuff
|
|
|
133
167
|
|
|
134
168
|
update_body_comment(number, resolved_repo, body) if body
|
|
135
169
|
|
|
136
|
-
|
|
170
|
+
return if options.none?
|
|
171
|
+
|
|
172
|
+
result = client.rest(:update_issue, resolved_repo, number, **options)
|
|
173
|
+
store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
|
|
174
|
+
result
|
|
137
175
|
end
|
|
138
176
|
|
|
139
177
|
# Finds a single GitHub issue by number and parses its PMS metadata.
|
|
@@ -147,7 +185,15 @@ module PlanMyStuff
|
|
|
147
185
|
client = PlanMyStuff.client
|
|
148
186
|
resolved_repo = client.resolve_repo(repo)
|
|
149
187
|
|
|
150
|
-
github_issue =
|
|
188
|
+
github_issue =
|
|
189
|
+
fetch_with_etag_cache(
|
|
190
|
+
client,
|
|
191
|
+
resolved_repo,
|
|
192
|
+
number,
|
|
193
|
+
rest_method: :issue,
|
|
194
|
+
cache_reader: :read_issue,
|
|
195
|
+
cache_writer: :write_issue,
|
|
196
|
+
)
|
|
151
197
|
|
|
152
198
|
if github_issue.respond_to?(:pull_request) && github_issue.pull_request
|
|
153
199
|
raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
|
|
@@ -170,43 +216,21 @@ module PlanMyStuff
|
|
|
170
216
|
client = PlanMyStuff.client
|
|
171
217
|
resolved_repo = client.resolve_repo(repo)
|
|
172
218
|
|
|
173
|
-
|
|
174
|
-
|
|
219
|
+
params = { state: state.to_s, page: page, per_page: per_page }
|
|
220
|
+
params[:labels] = labels.sort.join(',') if labels.any?
|
|
175
221
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
next if gi.respond_to?(:pull_request) && gi.pull_request
|
|
222
|
+
cached = PlanMyStuff::Cache.read_list(:issue, resolved_repo, params)
|
|
223
|
+
request_options = cached ? params.merge(headers: { 'If-None-Match' => cached[:etag] }) : params
|
|
179
224
|
|
|
180
|
-
|
|
181
|
-
end
|
|
182
|
-
end
|
|
225
|
+
github_issues = client.rest(:list_issues, resolved_repo, **request_options)
|
|
183
226
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
# @param number [Integer]
|
|
187
|
-
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
188
|
-
# @param user_ids [Array<Integer>]
|
|
189
|
-
#
|
|
190
|
-
# @return [Object] Octokit response
|
|
191
|
-
#
|
|
192
|
-
def add_viewers(number:, user_ids:, repo: nil)
|
|
193
|
-
modify_allowlist(number: number, repo: repo) do |allowlist|
|
|
194
|
-
allowlist | Array.wrap(user_ids)
|
|
227
|
+
if cached && not_modified?(client)
|
|
228
|
+
return cached[:body].map { |gi| build(gi, repo: resolved_repo) }
|
|
195
229
|
end
|
|
196
|
-
end
|
|
197
230
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
202
|
-
# @param user_ids [Array<Integer>]
|
|
203
|
-
#
|
|
204
|
-
# @return [Object] Octokit response
|
|
205
|
-
#
|
|
206
|
-
def remove_viewers(number:, user_ids:, repo: nil)
|
|
207
|
-
modify_allowlist(number: number, repo: repo) do |allowlist|
|
|
208
|
-
allowlist - Array.wrap(user_ids)
|
|
209
|
-
end
|
|
231
|
+
filtered = github_issues.reject { |gi| gi.respond_to?(:pull_request) && gi.pull_request }
|
|
232
|
+
store_list_etag_to_cache(client, :issue, resolved_repo, params, filtered)
|
|
233
|
+
filtered.map { |gi| build(gi, repo: resolved_repo) }
|
|
210
234
|
end
|
|
211
235
|
|
|
212
236
|
private
|
|
@@ -232,30 +256,6 @@ module PlanMyStuff
|
|
|
232
256
|
raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
|
|
233
257
|
end
|
|
234
258
|
|
|
235
|
-
# Reads an issue's metadata, yields the allowlist for modification,
|
|
236
|
-
# and PATCHes the issue body with the updated allowlist.
|
|
237
|
-
#
|
|
238
|
-
# @param number [Integer]
|
|
239
|
-
# @param repo [Symbol, String, nil]
|
|
240
|
-
#
|
|
241
|
-
# @return [Object] Octokit response
|
|
242
|
-
#
|
|
243
|
-
def modify_allowlist(number:, repo:)
|
|
244
|
-
client = PlanMyStuff.client
|
|
245
|
-
resolved_repo = client.resolve_repo(repo)
|
|
246
|
-
|
|
247
|
-
current = client.rest(:issue, resolved_repo, number)
|
|
248
|
-
current_body = current.respond_to?(:body) ? current.body : current[:body]
|
|
249
|
-
parsed = MetadataParser.parse(current_body)
|
|
250
|
-
|
|
251
|
-
existing_metadata = parsed[:metadata]
|
|
252
|
-
allowlist = Array.wrap(existing_metadata[:visibility_allowlist])
|
|
253
|
-
existing_metadata[:visibility_allowlist] = yield(allowlist)
|
|
254
|
-
|
|
255
|
-
new_body = MetadataParser.serialize(existing_metadata, parsed[:body])
|
|
256
|
-
client.rest(:update_issue, resolved_repo, number, body: new_body)
|
|
257
|
-
end
|
|
258
|
-
|
|
259
259
|
# Finds the first PMS comment on an issue and updates its body content,
|
|
260
260
|
# preserving the comment header and metadata.
|
|
261
261
|
#
|
|
@@ -275,59 +275,392 @@ module PlanMyStuff
|
|
|
275
275
|
end
|
|
276
276
|
|
|
277
277
|
def initialize(**attrs)
|
|
278
|
-
@
|
|
279
|
-
@raw_body = nil
|
|
280
|
-
@metadata = IssueMetadata.new
|
|
278
|
+
@body_dirty = false
|
|
281
279
|
super
|
|
282
|
-
@labels ||= []
|
|
283
280
|
end
|
|
284
281
|
|
|
285
282
|
# @param value [PlanMyStuff::Repo, Symbol, String, nil]
|
|
286
283
|
def repo=(value)
|
|
287
|
-
|
|
284
|
+
super(value.present? ? PlanMyStuff::Repo.resolve(value) : nil)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Assigning a new body marks the instance dirty so the next
|
|
288
|
+
# +save!+ rewrites the backing PMS body comment. Unsaved
|
|
289
|
+
# assignments are reflected by +#body+ until persisted or
|
|
290
|
+
# reloaded.
|
|
291
|
+
#
|
|
292
|
+
# @param value [String]
|
|
293
|
+
#
|
|
294
|
+
# @return [String]
|
|
295
|
+
#
|
|
296
|
+
def body=(value)
|
|
297
|
+
super
|
|
298
|
+
@body_dirty = true
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved)
|
|
302
|
+
def approvers
|
|
303
|
+
metadata.approvals
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# @return [Array<PlanMyStuff::Approval>] approvers who have not yet approved
|
|
307
|
+
def pending_approvals
|
|
308
|
+
approvers.select(&:pending?)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# @return [Boolean] true when at least one approver is required on this issue
|
|
312
|
+
def approvals_required?
|
|
313
|
+
approvers.any?
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# @return [Boolean] true when approvers are required AND every approver has approved
|
|
317
|
+
def fully_approved?
|
|
318
|
+
approvals_required? && pending_approvals.empty?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Adds user IDs to this issue's visibility allowlist (non-support
|
|
322
|
+
# users whose ID is in the allowlist can see internal comments).
|
|
323
|
+
#
|
|
324
|
+
# Fires +plan_my_stuff.issue.viewers_added+.
|
|
325
|
+
#
|
|
326
|
+
# @param user_ids [Array<Integer>, Integer]
|
|
327
|
+
# @param user [Object, nil] actor for the notification event
|
|
328
|
+
#
|
|
329
|
+
# @return [Array<Integer>] the new allowlist
|
|
330
|
+
#
|
|
331
|
+
def add_viewers(user_ids:, user: nil)
|
|
332
|
+
ids = Array.wrap(user_ids)
|
|
333
|
+
modify_allowlist { |allowlist| allowlist | ids }
|
|
334
|
+
PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
|
|
335
|
+
metadata.visibility_allowlist
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Removes user IDs from this issue's visibility allowlist.
|
|
339
|
+
#
|
|
340
|
+
# Fires +plan_my_stuff.issue.viewers_removed+.
|
|
341
|
+
#
|
|
342
|
+
# @param user_ids [Array<Integer>, Integer]
|
|
343
|
+
# @param user [Object, nil] actor for the notification event
|
|
344
|
+
#
|
|
345
|
+
# @return [Array<Integer>] the new allowlist
|
|
346
|
+
#
|
|
347
|
+
def remove_viewers(user_ids:, user: nil)
|
|
348
|
+
ids = Array.wrap(user_ids)
|
|
349
|
+
modify_allowlist { |allowlist| allowlist - ids }
|
|
350
|
+
PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
|
|
351
|
+
metadata.visibility_allowlist
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Adds approvers to this issue's required-approvals list. Idempotent:
|
|
355
|
+
# users already present are no-ops. Only support users may call this.
|
|
356
|
+
#
|
|
357
|
+
# Fires +plan_my_stuff.issue.approval_requested+ when any user is
|
|
358
|
+
# newly added. Also fires +plan_my_stuff.issue.approvals_invalidated+
|
|
359
|
+
# (+trigger: :approver_added+) when the new approvers flip the issue
|
|
360
|
+
# out of a fully-approved state.
|
|
361
|
+
#
|
|
362
|
+
# @param user_ids [Array<Integer>, Integer]
|
|
363
|
+
# @param user [Object, nil] actor; must be a support user
|
|
364
|
+
#
|
|
365
|
+
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
366
|
+
#
|
|
367
|
+
# @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
|
|
368
|
+
#
|
|
369
|
+
def request_approvals!(user_ids:, user: nil)
|
|
370
|
+
guard_support!(user)
|
|
371
|
+
ids = Array.wrap(user_ids).map(&:to_i)
|
|
372
|
+
|
|
373
|
+
just_added, was_fully_approved = modify_approvals do |current|
|
|
374
|
+
existing_ids = current.map(&:user_id)
|
|
375
|
+
new_ids = ids - existing_ids
|
|
376
|
+
added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
|
|
377
|
+
[current + added, added]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved)
|
|
381
|
+
just_added
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Removes approvers from this issue's required-approvals list. Only
|
|
385
|
+
# support users may call this. Removing a pending approver may flip
|
|
386
|
+
# the issue into +fully_approved?+ (fires +all_approved+). Removing
|
|
387
|
+
# an approved approver fires no events (state does not flip).
|
|
388
|
+
# Removing the last approver never fires aggregate events (issue no
|
|
389
|
+
# longer has +approvals_required?+).
|
|
390
|
+
#
|
|
391
|
+
# @param user_ids [Array<Integer>, Integer]
|
|
392
|
+
# @param user [Object, nil] actor; must be a support user
|
|
393
|
+
#
|
|
394
|
+
# @raise [PlanMyStuff::AuthorizationError] if +user+ is not support
|
|
395
|
+
#
|
|
396
|
+
# @return [Array<PlanMyStuff::Approval>] removed approval records
|
|
397
|
+
#
|
|
398
|
+
def remove_approvers!(user_ids:, user: nil)
|
|
399
|
+
guard_support!(user)
|
|
400
|
+
ids = Array.wrap(user_ids).map(&:to_i)
|
|
401
|
+
|
|
402
|
+
just_removed, was_fully_approved = modify_approvals do |current|
|
|
403
|
+
removed = current.select { |a| ids.include?(a.user_id) }
|
|
404
|
+
[current - removed, removed]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user)
|
|
408
|
+
just_removed
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Flips the caller's approval from +pending+ to +approved+. Only the
|
|
412
|
+
# approver themselves may call this. Fires
|
|
413
|
+
# +plan_my_stuff.issue.approval_granted+ and, when this flip
|
|
414
|
+
# completes the approval set, +plan_my_stuff.issue.all_approved+.
|
|
415
|
+
#
|
|
416
|
+
# @param user [Object, Integer] actor; must resolve to an approver currently +pending+
|
|
417
|
+
#
|
|
418
|
+
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
|
|
419
|
+
#
|
|
420
|
+
# @return [PlanMyStuff::Approval] the updated approval
|
|
421
|
+
#
|
|
422
|
+
def approve!(user:)
|
|
423
|
+
actor_id = resolve_actor_id!(user)
|
|
424
|
+
|
|
425
|
+
just_approved, was_fully_approved = modify_approvals do |current|
|
|
426
|
+
approval = current.find { |a| a.user_id == actor_id }
|
|
427
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
428
|
+
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") unless approval.pending?
|
|
429
|
+
|
|
430
|
+
approval.status = 'approved'
|
|
431
|
+
approval.approved_at = Time.current
|
|
432
|
+
[current, approval]
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved)
|
|
436
|
+
just_approved
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Flips an approved record back to +pending+. Approvers may revoke
|
|
440
|
+
# their own approval; support users may revoke any approver's
|
|
441
|
+
# approval by passing +target_user_id:+. Non-support callers passing
|
|
442
|
+
# a +target_user_id:+ that is not their own raise
|
|
443
|
+
# +AuthorizationError+.
|
|
444
|
+
#
|
|
445
|
+
# Fires +plan_my_stuff.issue.approval_revoked+ and, when this flip
|
|
446
|
+
# drops the issue out of +fully_approved?+,
|
|
447
|
+
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+).
|
|
448
|
+
#
|
|
449
|
+
# @param user [Object, Integer] the caller
|
|
450
|
+
# @param target_user_id [Integer, nil] approver whose approval should be revoked; defaults to the caller
|
|
451
|
+
#
|
|
452
|
+
# @raise [PlanMyStuff::AuthorizationError] when a non-support caller targets another user
|
|
453
|
+
# @raise [PlanMyStuff::ValidationError] when the target is not in the approvers list or is not currently approved
|
|
454
|
+
#
|
|
455
|
+
# @return [PlanMyStuff::Approval] the updated approval
|
|
456
|
+
#
|
|
457
|
+
def revoke_approval!(user:, target_user_id: nil)
|
|
458
|
+
actor_id = resolve_actor_id!(user)
|
|
459
|
+
caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
460
|
+
target_id = target_user_id&.to_i || actor_id
|
|
461
|
+
|
|
462
|
+
if !caller_is_support && target_id != actor_id
|
|
463
|
+
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's approval")
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
just_revoked, was_fully_approved = modify_approvals do |current|
|
|
467
|
+
approval = current.find { |a| a.user_id == target_id }
|
|
468
|
+
raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
|
|
469
|
+
raise(PlanMyStuff::ValidationError, "User #{target_id} is not currently approved") unless approval.approved?
|
|
470
|
+
|
|
471
|
+
approval.status = 'pending'
|
|
472
|
+
approval.approved_at = nil
|
|
473
|
+
[current, approval]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
finish_state_change(
|
|
477
|
+
:approval_revoked,
|
|
478
|
+
just_revoked,
|
|
479
|
+
user: user,
|
|
480
|
+
was_fully_approved: was_fully_approved,
|
|
481
|
+
trigger: :revoked,
|
|
482
|
+
)
|
|
483
|
+
just_revoked
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Marks the issue as waiting on an end-user reply. Sets
|
|
487
|
+
# +metadata.waiting_on_user_at+ to now, (re)computes
|
|
488
|
+
# +metadata.next_reminder_at+, and adds the configured
|
|
489
|
+
# +waiting_on_user_label+ to the issue. Called from
|
|
490
|
+
# +Comment.create!+ when a support user posts a comment with
|
|
491
|
+
# +waiting_on_reply: true+, and from the +Issues::WaitingsController+
|
|
492
|
+
# toggle.
|
|
493
|
+
#
|
|
494
|
+
# @param user [Object, nil] actor for the label notification event
|
|
495
|
+
#
|
|
496
|
+
# @return [self]
|
|
497
|
+
#
|
|
498
|
+
def enter_waiting_on_user!(user: nil)
|
|
499
|
+
now = Time.now.utc
|
|
500
|
+
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
501
|
+
|
|
502
|
+
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
503
|
+
PlanMyStuff::Label.add(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
504
|
+
|
|
505
|
+
self.class.update!(
|
|
506
|
+
number: number,
|
|
507
|
+
repo: repo,
|
|
508
|
+
metadata: {
|
|
509
|
+
waiting_on_user_at: now.iso8601,
|
|
510
|
+
next_reminder_at: format_next_reminder_at(from: now),
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
reload
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Clears the waiting-on-user state: removes the label, clears
|
|
517
|
+
# +metadata.waiting_on_user_at+, and clears
|
|
518
|
+
# +metadata.next_reminder_at+ unless a waiting-on-approval timer
|
|
519
|
+
# is still active. No-ops if the issue is not currently waiting
|
|
520
|
+
# on a user reply.
|
|
521
|
+
#
|
|
522
|
+
# @return [self]
|
|
523
|
+
#
|
|
524
|
+
def clear_waiting_on_user!
|
|
525
|
+
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
526
|
+
return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
|
|
527
|
+
|
|
528
|
+
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
529
|
+
|
|
530
|
+
self.class.update!(
|
|
531
|
+
number: number,
|
|
532
|
+
repo: repo,
|
|
533
|
+
metadata: {
|
|
534
|
+
waiting_on_user_at: nil,
|
|
535
|
+
next_reminder_at: metadata.waiting_on_approval_at ? format_time(metadata.next_reminder_at) : nil,
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
reload
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Reopens an issue that was auto-closed by the inactivity sweep,
|
|
542
|
+
# clears +metadata.closed_by_inactivity+, and emits
|
|
543
|
+
# +plan_my_stuff.issue.reopened_by_reply+ carrying the reopening
|
|
544
|
+
# comment. Does not emit the regular +issue.reopened+ event \-
|
|
545
|
+
# subscribers that specifically care about this flow subscribe
|
|
546
|
+
# to the dedicated event.
|
|
547
|
+
#
|
|
548
|
+
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
549
|
+
# @param user [Object, nil] actor for the notification event
|
|
550
|
+
#
|
|
551
|
+
# @return [self]
|
|
552
|
+
#
|
|
553
|
+
def reopen_by_reply!(comment:, user: nil)
|
|
554
|
+
inactive_label = PlanMyStuff.configuration.user_inactive_label
|
|
555
|
+
PlanMyStuff::Label.remove(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
556
|
+
|
|
557
|
+
self.class.update!(
|
|
558
|
+
number: number,
|
|
559
|
+
repo: repo,
|
|
560
|
+
state: :open,
|
|
561
|
+
metadata: { closed_by_inactivity: false },
|
|
562
|
+
)
|
|
563
|
+
reload
|
|
564
|
+
|
|
565
|
+
PlanMyStuff::Notifications.instrument(
|
|
566
|
+
'issue.reopened_by_reply',
|
|
567
|
+
self,
|
|
568
|
+
user: user,
|
|
569
|
+
comment: comment,
|
|
570
|
+
)
|
|
571
|
+
self
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Tags the issue with the configured +archived_label+, removes it
|
|
575
|
+
# from every Projects V2 board it belongs to, locks its
|
|
576
|
+
# conversation on GitHub, and stamps +metadata.archived_at+.
|
|
577
|
+
# Emits +plan_my_stuff.issue.archived+ on success.
|
|
578
|
+
#
|
|
579
|
+
# No-op (no network calls, no event) when the issue is already
|
|
580
|
+
# archived (either +metadata.archived_at+ is set or the archived
|
|
581
|
+
# label is already on the issue).
|
|
582
|
+
#
|
|
583
|
+
# @param now [Time] clock reference for +metadata.archived_at+
|
|
584
|
+
#
|
|
585
|
+
# @return [self]
|
|
586
|
+
#
|
|
587
|
+
def archive!(now: Time.now.utc)
|
|
588
|
+
label = PlanMyStuff.configuration.archived_label
|
|
589
|
+
return self unless state == 'closed'
|
|
590
|
+
|
|
591
|
+
return self if metadata.archived_at.present?
|
|
592
|
+
return self if labels.include?(label)
|
|
593
|
+
|
|
594
|
+
self.class.update!(
|
|
595
|
+
number: number,
|
|
596
|
+
repo: repo,
|
|
597
|
+
metadata: { archived_at: now.utc.iso8601 },
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
601
|
+
PlanMyStuff::Label.add(issue: self, labels: [label])
|
|
602
|
+
|
|
603
|
+
remove_from_all_projects!
|
|
604
|
+
|
|
605
|
+
PlanMyStuff.client.rest(:lock_issue, repo.full_name, number)
|
|
606
|
+
|
|
607
|
+
reload
|
|
608
|
+
|
|
609
|
+
PlanMyStuff::Notifications.instrument(
|
|
610
|
+
'issue.archived',
|
|
611
|
+
self,
|
|
612
|
+
reason: :aged_closed,
|
|
613
|
+
)
|
|
614
|
+
self
|
|
288
615
|
end
|
|
289
616
|
|
|
290
|
-
# Persists the issue. Creates if new,
|
|
617
|
+
# Persists the issue. Creates if new, otherwise performs a full
|
|
618
|
+
# write: serializes +@metadata+ into the GitHub issue body and
|
|
619
|
+
# PATCHes title/state/labels. When +#body=+ has been called since
|
|
620
|
+
# the last load, also rewrites the PMS body comment. Always
|
|
621
|
+
# reloads afterwards.
|
|
291
622
|
#
|
|
292
623
|
# @raise [PlanMyStuff::StaleObjectError] on update if stale
|
|
293
624
|
#
|
|
294
625
|
# @return [self]
|
|
295
626
|
#
|
|
296
|
-
def save!
|
|
627
|
+
def save!(user: nil, skip_notification: false)
|
|
297
628
|
if new_record?
|
|
298
629
|
created = self.class.create!(
|
|
299
630
|
title: title,
|
|
300
631
|
body: body,
|
|
301
632
|
repo: repo,
|
|
302
633
|
labels: labels || [],
|
|
634
|
+
user: user || metadata.created_by,
|
|
635
|
+
metadata: metadata.custom_fields.to_h,
|
|
636
|
+
visibility: metadata.visibility,
|
|
637
|
+
visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
|
|
303
638
|
)
|
|
304
639
|
hydrate_from_issue(created)
|
|
305
640
|
else
|
|
306
|
-
|
|
641
|
+
captured_changes = changes.dup
|
|
642
|
+
persist_update!
|
|
643
|
+
instrument_update(captured_changes, user) unless skip_notification
|
|
307
644
|
end
|
|
308
645
|
|
|
309
646
|
self
|
|
310
647
|
end
|
|
311
648
|
|
|
312
|
-
#
|
|
313
|
-
#
|
|
649
|
+
# Applies +attrs+ to this instance in-memory then calls +save!+.
|
|
650
|
+
# Supports +title:+, +body:+, +state:+, +labels:+, +assignees:+,
|
|
651
|
+
# and +metadata:+. The +metadata:+ kwarg is a hash whose keys are
|
|
652
|
+
# merged into the existing +metadata+ (top-level attributes
|
|
653
|
+
# assigned directly; +:custom_fields+ merged key-by-key).
|
|
314
654
|
#
|
|
315
|
-
# @param
|
|
655
|
+
# @param user [Object, nil] actor for notification events
|
|
316
656
|
#
|
|
317
657
|
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
318
658
|
#
|
|
319
659
|
# @return [self]
|
|
320
660
|
#
|
|
321
|
-
def update!(**attrs)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
self.class.update!(
|
|
325
|
-
number: number,
|
|
326
|
-
repo: repo,
|
|
327
|
-
**attrs,
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
reload
|
|
661
|
+
def update!(user: nil, skip_notification: false, **attrs)
|
|
662
|
+
apply_update_attrs!(attrs)
|
|
663
|
+
save!(user: user, skip_notification: skip_notification)
|
|
331
664
|
end
|
|
332
665
|
|
|
333
666
|
# Re-fetches this issue from GitHub and updates all local attributes.
|
|
@@ -348,6 +681,14 @@ module PlanMyStuff
|
|
|
348
681
|
@comments ||= load_comments
|
|
349
682
|
end
|
|
350
683
|
|
|
684
|
+
# GitHub web URL for this issue, for escape-hatch "View on GitHub" links.
|
|
685
|
+
#
|
|
686
|
+
# @return [String, nil]
|
|
687
|
+
#
|
|
688
|
+
def html_url
|
|
689
|
+
safe_read_field(github_response, :html_url)
|
|
690
|
+
end
|
|
691
|
+
|
|
351
692
|
# @return [Boolean]
|
|
352
693
|
def pms_issue?
|
|
353
694
|
metadata.schema_version.present?
|
|
@@ -373,14 +714,14 @@ module PlanMyStuff
|
|
|
373
714
|
# @return [String, nil]
|
|
374
715
|
#
|
|
375
716
|
def body
|
|
376
|
-
return
|
|
377
|
-
|
|
378
|
-
return
|
|
717
|
+
return super if new_record?
|
|
718
|
+
return super if @body_dirty
|
|
719
|
+
return super unless pms_issue?
|
|
379
720
|
|
|
380
721
|
bc = body_comment
|
|
381
722
|
return bc.body_without_header if bc.present?
|
|
382
723
|
|
|
383
|
-
|
|
724
|
+
super
|
|
384
725
|
end
|
|
385
726
|
|
|
386
727
|
# Delegates visibility check to metadata.
|
|
@@ -398,93 +739,1053 @@ module PlanMyStuff
|
|
|
398
739
|
end
|
|
399
740
|
end
|
|
400
741
|
|
|
401
|
-
|
|
742
|
+
# Lazy-memoized array of +Issue+ objects for +:related+ links.
|
|
743
|
+
# Silently drops targets that 404 so a dangling pointer doesn't
|
|
744
|
+
# break the rest of the list.
|
|
745
|
+
#
|
|
746
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
747
|
+
#
|
|
748
|
+
def related
|
|
749
|
+
links_cache[:related] ||= fetch_related
|
|
750
|
+
end
|
|
402
751
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
self.repo = repo
|
|
752
|
+
# Adds a +:related+ link to +target+ and, unless this call is
|
|
753
|
+
# already a reciprocal, mirrors the link back on +target+ so
|
|
754
|
+
# the pairing is symmetric. Dedups on
|
|
755
|
+
# +(type, issue_number, repo)+ - re-adding is a no-op.
|
|
756
|
+
#
|
|
757
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
758
|
+
# @param user [Object, nil] actor for notification events
|
|
759
|
+
# @param reciprocal [Boolean] internal flag; set by the mirror call
|
|
760
|
+
#
|
|
761
|
+
# @return [PlanMyStuff::Link]
|
|
762
|
+
#
|
|
763
|
+
def add_related!(target, user: nil, reciprocal: false)
|
|
764
|
+
link = build_link(target, type: :related)
|
|
765
|
+
validate_not_self!(link)
|
|
418
766
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
@body = parsed[:body]
|
|
422
|
-
@persisted = true
|
|
423
|
-
@comments = nil
|
|
424
|
-
end
|
|
767
|
+
existing = current_links
|
|
768
|
+
return link if existing.include?(link)
|
|
425
769
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
#
|
|
430
|
-
# @return [void]
|
|
431
|
-
#
|
|
432
|
-
def hydrate_from_issue(other)
|
|
433
|
-
@number = other.number
|
|
434
|
-
@title = other.title
|
|
435
|
-
@state = other.state
|
|
436
|
-
@body = other.instance_variable_get(:@body)
|
|
437
|
-
@raw_body = other.raw_body
|
|
438
|
-
@updated_at = other.updated_at
|
|
439
|
-
@labels = other.labels
|
|
440
|
-
@repo = other.repo
|
|
441
|
-
@metadata = other.metadata
|
|
442
|
-
@persisted = true
|
|
443
|
-
@comments = nil
|
|
770
|
+
persist_links!(existing + [link])
|
|
771
|
+
unless reciprocal
|
|
772
|
+
mirror_on_target(link, user: user) { |other| other.add_related!(self, user: user, reciprocal: true) }
|
|
444
773
|
end
|
|
445
774
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
#
|
|
449
|
-
# @raise [PlanMyStuff::StaleObjectError]
|
|
450
|
-
#
|
|
451
|
-
# @return [void]
|
|
452
|
-
#
|
|
453
|
-
def raise_if_stale!
|
|
454
|
-
return if new_record?
|
|
455
|
-
return if updated_at.nil?
|
|
775
|
+
link
|
|
776
|
+
end
|
|
456
777
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
778
|
+
# Removes a +:related+ link to +target+ and, unless this call is
|
|
779
|
+
# already a reciprocal, mirrors the removal on +target+. No-op
|
|
780
|
+
# when the link isn't present locally.
|
|
781
|
+
#
|
|
782
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
783
|
+
# @param user [Object, nil]
|
|
784
|
+
# @param reciprocal [Boolean]
|
|
785
|
+
#
|
|
786
|
+
# @return [PlanMyStuff::Link]
|
|
787
|
+
#
|
|
788
|
+
def remove_related!(target, user: nil, reciprocal: false)
|
|
789
|
+
link = build_link(target, type: :related)
|
|
790
|
+
validate_not_self!(link)
|
|
460
791
|
|
|
461
|
-
|
|
462
|
-
|
|
792
|
+
existing = current_links
|
|
793
|
+
return link if existing.exclude?(link)
|
|
463
794
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
remote_updated_at: remote_time,
|
|
468
|
-
))
|
|
795
|
+
persist_links!(existing.reject { |l| l == link })
|
|
796
|
+
unless reciprocal
|
|
797
|
+
mirror_on_target(link, user: user) { |other| other.remove_related!(self, user: user, reciprocal: true) }
|
|
469
798
|
end
|
|
470
799
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
raw = read_field(github_issue, :labels) || []
|
|
474
|
-
raw.map { |label| label_name(label) }
|
|
475
|
-
end
|
|
800
|
+
link
|
|
801
|
+
end
|
|
476
802
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
803
|
+
# Lazy-memoized parent issue via GitHub's native sub-issues API.
|
|
804
|
+
# GitHub enforces at most one parent per issue.
|
|
805
|
+
#
|
|
806
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
807
|
+
#
|
|
808
|
+
def parent
|
|
809
|
+
return links_cache[:parent] if links_cache.key?(:parent)
|
|
481
810
|
|
|
482
|
-
|
|
483
|
-
|
|
811
|
+
links_cache[:parent] = fetch_parent
|
|
812
|
+
end
|
|
484
813
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
814
|
+
# Lazy-memoized sub-issues via GitHub's native sub-issues API.
|
|
815
|
+
#
|
|
816
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
817
|
+
#
|
|
818
|
+
def sub_tickets
|
|
819
|
+
links_cache[:sub_tickets] ||= fetch_sub_tickets
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Adds +target+ as a sub-issue of self via
|
|
823
|
+
# +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
824
|
+
# notifications are handled by GitHub itself.
|
|
825
|
+
#
|
|
826
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
827
|
+
#
|
|
828
|
+
# @return [PlanMyStuff::Link]
|
|
829
|
+
#
|
|
830
|
+
def add_sub_issue!(target)
|
|
831
|
+
mutate_sub_issue!(target, method: :post, path: sub_issues_path)
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# Removes +target+ as a sub-issue of self via
|
|
835
|
+
# +DELETE /issues/{number}/sub_issue+ (singular).
|
|
836
|
+
#
|
|
837
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
838
|
+
#
|
|
839
|
+
# @return [PlanMyStuff::Link]
|
|
840
|
+
#
|
|
841
|
+
def remove_sub_issue!(target)
|
|
842
|
+
mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Makes +target+ the parent of self. If self already has a parent,
|
|
846
|
+
# it is detached first. Returns a +Link+ describing the new
|
|
847
|
+
# +:parent+ relationship.
|
|
848
|
+
#
|
|
849
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
850
|
+
#
|
|
851
|
+
# @return [PlanMyStuff::Link]
|
|
852
|
+
#
|
|
853
|
+
def set_parent!(target)
|
|
854
|
+
parent.presence&.remove_sub_issue!(self)
|
|
855
|
+
|
|
856
|
+
target_issue = resolve_target_issue(target, type: :parent)
|
|
857
|
+
target_issue.add_sub_issue!(self)
|
|
858
|
+
invalidate_links_cache!
|
|
859
|
+
|
|
860
|
+
build_link(target_issue, type: :parent)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Detaches self from its current parent, if any. Returns the
|
|
864
|
+
# +Link+ that was removed, or nil when there was no parent.
|
|
865
|
+
#
|
|
866
|
+
# @return [PlanMyStuff::Link, nil]
|
|
867
|
+
#
|
|
868
|
+
def remove_parent!
|
|
869
|
+
current = parent
|
|
870
|
+
return if current.nil?
|
|
871
|
+
|
|
872
|
+
current.remove_sub_issue!(self)
|
|
873
|
+
invalidate_links_cache!
|
|
874
|
+
|
|
875
|
+
build_link(current, type: :parent)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# Lazy-memoized issues that block self (i.e. self is blocked by
|
|
879
|
+
# each returned issue) via GitHub's native issue-dependency REST
|
|
880
|
+
# API.
|
|
881
|
+
#
|
|
882
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
883
|
+
#
|
|
884
|
+
def blocked_by
|
|
885
|
+
links_cache[:blocked_by] ||= fetch_dependencies('blocked_by')
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Lazy-memoized issues that self blocks.
|
|
889
|
+
#
|
|
890
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
891
|
+
#
|
|
892
|
+
def blocking
|
|
893
|
+
links_cache[:blocking] ||= fetch_dependencies('blocking')
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# Records that +target+ blocks self. Native GitHub action;
|
|
897
|
+
# notifications are handled by GitHub itself.
|
|
898
|
+
#
|
|
899
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
900
|
+
#
|
|
901
|
+
# @return [PlanMyStuff::Link]
|
|
902
|
+
#
|
|
903
|
+
def add_blocker!(target)
|
|
904
|
+
link = build_link(target, type: :blocked_by)
|
|
905
|
+
validate_not_self!(link)
|
|
906
|
+
|
|
907
|
+
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
908
|
+
PlanMyStuff.client.rest(
|
|
909
|
+
:post,
|
|
910
|
+
dependency_path('blocked_by'),
|
|
911
|
+
{ issue_id: target_issue.__send__(:require_github_id!) },
|
|
912
|
+
)
|
|
913
|
+
invalidate_links_cache!
|
|
914
|
+
link
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
# Removes the record that +target+ blocks self.
|
|
918
|
+
#
|
|
919
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
920
|
+
#
|
|
921
|
+
# @return [PlanMyStuff::Link]
|
|
922
|
+
#
|
|
923
|
+
def remove_blocker!(target)
|
|
924
|
+
link = build_link(target, type: :blocked_by)
|
|
925
|
+
validate_not_self!(link)
|
|
926
|
+
|
|
927
|
+
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
928
|
+
PlanMyStuff.client.rest(
|
|
929
|
+
:delete,
|
|
930
|
+
"#{dependency_path('blocked_by')}/#{target_issue.__send__(:require_github_id!)}",
|
|
931
|
+
)
|
|
932
|
+
invalidate_links_cache!
|
|
933
|
+
link
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# Lazy-memoized issue that self was marked as duplicate of, via
|
|
937
|
+
# GitHub's native close-as-duplicate. Returns nil for issues that
|
|
938
|
+
# are open or closed for other reasons.
|
|
939
|
+
#
|
|
940
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
941
|
+
#
|
|
942
|
+
def duplicate_of
|
|
943
|
+
return links_cache[:duplicate_of] if links_cache.key?(:duplicate_of)
|
|
944
|
+
|
|
945
|
+
links_cache[:duplicate_of] = fetch_duplicate_of
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Closes self as a duplicate of +target+ via GitHub's native
|
|
949
|
+
# close-as-duplicate, carrying over viewers, assignees, and a
|
|
950
|
+
# back-pointer comment on the target.
|
|
951
|
+
#
|
|
952
|
+
# Side effects, in order:
|
|
953
|
+
# 1. Resolves +target+; raises +ValidationError+ if missing.
|
|
954
|
+
# 2. Raises +ValidationError+ when self is already closed.
|
|
955
|
+
# 3. Merges self's +visibility_allowlist+ onto target.
|
|
956
|
+
# 4. Merges self's assignees onto target.
|
|
957
|
+
# 5. Posts a PMS comment on target with the back-pointer.
|
|
958
|
+
# 6. Closes self with +state_reason: :duplicate+ and
|
|
959
|
+
# +duplicate_of: { owner:, repo:, number: }+.
|
|
960
|
+
# 7. Reloads self; invalidates link caches.
|
|
961
|
+
# 8. Fires +plan_my_stuff.issue.marked_duplicate+.
|
|
962
|
+
#
|
|
963
|
+
# Partial failures are not rolled back - GitHub retains whatever
|
|
964
|
+
# side effects succeeded before the failing step.
|
|
965
|
+
#
|
|
966
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
967
|
+
# @param user [Object, nil] actor for notification + comment
|
|
968
|
+
#
|
|
969
|
+
# @return [PlanMyStuff::Link]
|
|
970
|
+
#
|
|
971
|
+
def mark_duplicate!(target, user: nil)
|
|
972
|
+
raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
|
|
973
|
+
|
|
974
|
+
target_issue = resolve_duplicate_target!(target)
|
|
975
|
+
merge_visibility_allowlist_onto(target_issue)
|
|
976
|
+
merge_assignees_onto(target_issue)
|
|
977
|
+
post_duplicate_back_pointer(target_issue, user: user)
|
|
978
|
+
close_as_duplicate!(target_issue)
|
|
979
|
+
|
|
980
|
+
reload
|
|
981
|
+
invalidate_links_cache!
|
|
982
|
+
PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
|
|
983
|
+
|
|
984
|
+
build_link(target_issue, type: :duplicate_of)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# GitHub GraphQL node ID (required for native sub-issue mutations).
|
|
988
|
+
# Read from the hydrated REST response.
|
|
989
|
+
#
|
|
990
|
+
# @return [String, nil]
|
|
991
|
+
#
|
|
992
|
+
def github_node_id
|
|
993
|
+
safe_read_field(github_response, :node_id)
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# GitHub database ID (required for the REST issue-dependency API,
|
|
997
|
+
# which takes integer issue_id rather than issue number).
|
|
998
|
+
#
|
|
999
|
+
# @return [Integer, nil]
|
|
1000
|
+
#
|
|
1001
|
+
def github_id
|
|
1002
|
+
safe_read_field(github_response, :id)
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
private
|
|
1006
|
+
|
|
1007
|
+
# Yields +self.metadata.visibility_allowlist+ for modification,
|
|
1008
|
+
# persists the updated allowlist via the class-level +update!+,
|
|
1009
|
+
# and reloads +self+ so subsequent reads see the fresh state.
|
|
1010
|
+
#
|
|
1011
|
+
# @yieldparam allowlist [Array<Integer>]
|
|
1012
|
+
# @yieldreturn [Array<Integer>] the new allowlist
|
|
1013
|
+
#
|
|
1014
|
+
# @return [void]
|
|
1015
|
+
#
|
|
1016
|
+
def modify_allowlist
|
|
1017
|
+
new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
|
|
1018
|
+
self.class.update!(
|
|
1019
|
+
number: number,
|
|
1020
|
+
repo: repo,
|
|
1021
|
+
metadata: { visibility_allowlist: new_allowlist },
|
|
1022
|
+
)
|
|
1023
|
+
reload
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
# Captures +fully_approved?+ state, yields the current approvals
|
|
1027
|
+
# (deep-copied) for mutation, persists the new list to GitHub, and
|
|
1028
|
+
# reloads +self+. Returns +[extra, was_fully_approved]+.
|
|
1029
|
+
#
|
|
1030
|
+
# @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
|
|
1031
|
+
# @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
|
|
1032
|
+
#
|
|
1033
|
+
# @return [Array(Object, Boolean)]
|
|
1034
|
+
#
|
|
1035
|
+
def modify_approvals
|
|
1036
|
+
was_fully_approved = fully_approved?
|
|
1037
|
+
was_pending_count = metadata.approvals.count(&:pending?)
|
|
1038
|
+
current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
|
|
1039
|
+
|
|
1040
|
+
new_list, extra = yield(current)
|
|
1041
|
+
|
|
1042
|
+
new_pending_count = new_list.count(&:pending?)
|
|
1043
|
+
metadata_updates = { approvals: new_list.map(&:to_h) }
|
|
1044
|
+
metadata_updates.merge!(waiting_on_approval_metadata_updates(was_pending_count, new_pending_count))
|
|
1045
|
+
|
|
1046
|
+
self.class.update!(number: number, repo: repo, metadata: metadata_updates)
|
|
1047
|
+
reload
|
|
1048
|
+
|
|
1049
|
+
sync_waiting_on_approval_label(was_pending_count, new_pending_count)
|
|
1050
|
+
|
|
1051
|
+
[extra, was_fully_approved]
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
# Computes the metadata delta for the waiting-on-approval timer
|
|
1055
|
+
# based on the change in pending-approval count. The timer resets
|
|
1056
|
+
# only when pending count goes UP (add approver, revoke-to-pending)
|
|
1057
|
+
# so that remaining pending approvers keep their original schedule
|
|
1058
|
+
# when a peer approves. Drop-to-zero clears the timer entirely.
|
|
1059
|
+
#
|
|
1060
|
+
# @param was [Integer] pending count before the mutation
|
|
1061
|
+
# @param now [Integer] pending count after the mutation
|
|
1062
|
+
#
|
|
1063
|
+
# @return [Hash]
|
|
1064
|
+
#
|
|
1065
|
+
def waiting_on_approval_metadata_updates(was, now)
|
|
1066
|
+
if now > was
|
|
1067
|
+
ts = Time.now.utc
|
|
1068
|
+
{
|
|
1069
|
+
waiting_on_approval_at: ts.iso8601,
|
|
1070
|
+
next_reminder_at: format_next_reminder_at(from: ts),
|
|
1071
|
+
}
|
|
1072
|
+
elsif now.zero? && was.positive?
|
|
1073
|
+
{
|
|
1074
|
+
waiting_on_approval_at: nil,
|
|
1075
|
+
next_reminder_at: metadata.waiting_on_user_at ? format_time(metadata.next_reminder_at) : nil,
|
|
1076
|
+
}
|
|
1077
|
+
else
|
|
1078
|
+
{}
|
|
1079
|
+
end
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# Adds or removes the configured waiting-on-approval label when the
|
|
1083
|
+
# pending-approval count crosses the zero boundary. Mutations that
|
|
1084
|
+
# stay on the same side of zero leave the label untouched.
|
|
1085
|
+
#
|
|
1086
|
+
# @param was [Integer] pending count before the mutation
|
|
1087
|
+
# @param now [Integer] pending count after the mutation
|
|
1088
|
+
#
|
|
1089
|
+
# @return [void]
|
|
1090
|
+
#
|
|
1091
|
+
def sync_waiting_on_approval_label(was, now)
|
|
1092
|
+
label = PlanMyStuff.configuration.waiting_on_approval_label
|
|
1093
|
+
|
|
1094
|
+
if now.positive? && was.zero?
|
|
1095
|
+
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
1096
|
+
PlanMyStuff::Label.add(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1097
|
+
elsif now.zero? && was.positive?
|
|
1098
|
+
PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)
|
|
1099
|
+
end
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# Raises +AuthorizationError+ unless +user+ resolves to a support
|
|
1103
|
+
# user. +nil+ user is treated as unauthorized.
|
|
1104
|
+
#
|
|
1105
|
+
# @param user [Object, Integer, nil]
|
|
1106
|
+
#
|
|
1107
|
+
# @return [void]
|
|
1108
|
+
#
|
|
1109
|
+
def guard_support!(user)
|
|
1110
|
+
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
1111
|
+
return if resolved && PlanMyStuff::UserResolver.support?(resolved)
|
|
1112
|
+
|
|
1113
|
+
raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
# Resolves +user+ to an integer user_id. Raises +ArgumentError+
|
|
1117
|
+
# when +user+ is +nil+.
|
|
1118
|
+
#
|
|
1119
|
+
# @param user [Object, Integer]
|
|
1120
|
+
#
|
|
1121
|
+
# @return [Integer]
|
|
1122
|
+
#
|
|
1123
|
+
def resolve_actor_id!(user)
|
|
1124
|
+
raise(ArgumentError, 'user: is required') if user.nil?
|
|
1125
|
+
|
|
1126
|
+
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
1127
|
+
PlanMyStuff::UserResolver.user_id(resolved)
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# Fires +approval_requested+ (when any users were newly added) and,
|
|
1131
|
+
# if the aggregate state flipped out of fully-approved, the
|
|
1132
|
+
# +approvals_invalidated+ follow-up.
|
|
1133
|
+
#
|
|
1134
|
+
# @param added [Array<PlanMyStuff::Approval>]
|
|
1135
|
+
# @param user [Object, nil]
|
|
1136
|
+
# @param was_fully_approved [Boolean]
|
|
1137
|
+
#
|
|
1138
|
+
# @return [void]
|
|
1139
|
+
#
|
|
1140
|
+
def finish_request_approvals(added, user:, was_fully_approved:)
|
|
1141
|
+
return if added.empty?
|
|
1142
|
+
|
|
1143
|
+
PlanMyStuff::Notifications.instrument(
|
|
1144
|
+
'issue.approval_requested',
|
|
1145
|
+
self,
|
|
1146
|
+
user: user,
|
|
1147
|
+
approvals: added,
|
|
1148
|
+
)
|
|
1149
|
+
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
# Fires the granular event (+approval_granted+ / +approval_revoked+)
|
|
1153
|
+
# then any aggregate follow-up triggered by the state flip.
|
|
1154
|
+
#
|
|
1155
|
+
# @param event [Symbol] +:approval_granted+ or +:approval_revoked+
|
|
1156
|
+
# @param approval [PlanMyStuff::Approval]
|
|
1157
|
+
# @param user [Object, nil]
|
|
1158
|
+
# @param was_fully_approved [Boolean]
|
|
1159
|
+
# @param trigger [Symbol, nil] passed through to +approvals_invalidated+
|
|
1160
|
+
#
|
|
1161
|
+
# @return [void]
|
|
1162
|
+
#
|
|
1163
|
+
def finish_state_change(event, approval, user:, was_fully_approved:, trigger: nil)
|
|
1164
|
+
PlanMyStuff::Notifications.instrument(
|
|
1165
|
+
"issue.#{event}",
|
|
1166
|
+
self,
|
|
1167
|
+
user: user,
|
|
1168
|
+
approval: approval,
|
|
1169
|
+
)
|
|
1170
|
+
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
# Fires +all_approved+ or +approvals_invalidated+ based on whether
|
|
1174
|
+
# +fully_approved?+ flipped. Suppresses +approvals_invalidated+
|
|
1175
|
+
# when the issue no longer has any approvers required (dropping
|
|
1176
|
+
# the list to empty is not an invalidation).
|
|
1177
|
+
#
|
|
1178
|
+
# @param was_fully_approved [Boolean]
|
|
1179
|
+
# @param trigger [Symbol, nil]
|
|
1180
|
+
# @param user [Object, nil]
|
|
1181
|
+
#
|
|
1182
|
+
# @return [void]
|
|
1183
|
+
#
|
|
1184
|
+
def emit_aggregate_events(was_fully_approved:, trigger:, user:)
|
|
1185
|
+
now = fully_approved?
|
|
1186
|
+
|
|
1187
|
+
if !was_fully_approved && now
|
|
1188
|
+
PlanMyStuff::Notifications.instrument('issue.all_approved', self, user: user)
|
|
1189
|
+
elsif was_fully_approved && !now && approvals_required?
|
|
1190
|
+
PlanMyStuff::Notifications.instrument(
|
|
1191
|
+
'issue.approvals_invalidated',
|
|
1192
|
+
self,
|
|
1193
|
+
user: user,
|
|
1194
|
+
trigger: trigger,
|
|
1195
|
+
)
|
|
1196
|
+
end
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
# Populates this instance from a GitHub API response.
|
|
1200
|
+
#
|
|
1201
|
+
# @param github_issue [Object] Octokit issue response
|
|
1202
|
+
# @param repo [String] resolved repo path
|
|
1203
|
+
#
|
|
1204
|
+
# @return [void]
|
|
1205
|
+
#
|
|
1206
|
+
def hydrate_from_github(github_issue, repo:)
|
|
1207
|
+
@github_response = github_issue
|
|
1208
|
+
self.number = read_field(github_issue, :number)
|
|
1209
|
+
self.title = read_field(github_issue, :title)
|
|
1210
|
+
self.state = read_field(github_issue, :state)
|
|
1211
|
+
self.raw_body = read_field(github_issue, :body) || ''
|
|
1212
|
+
self.updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
|
|
1213
|
+
self.closed_at = parse_github_time(safe_read_field(github_issue, :closed_at))
|
|
1214
|
+
self.locked = safe_read_field(github_issue, :locked) || false
|
|
1215
|
+
self.labels = extract_labels(github_issue)
|
|
1216
|
+
self.repo = repo
|
|
1217
|
+
|
|
1218
|
+
parsed = MetadataParser.parse(raw_body)
|
|
1219
|
+
self.metadata = IssueMetadata.from_hash(parsed[:metadata])
|
|
1220
|
+
self.body = parsed[:body]
|
|
1221
|
+
@body_dirty = false
|
|
1222
|
+
persisted!
|
|
1223
|
+
@comments = nil
|
|
1224
|
+
invalidate_links_cache!
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# Copies attributes from another Issue instance into self.
|
|
1228
|
+
#
|
|
1229
|
+
# @param other [PlanMyStuff::Issue]
|
|
1230
|
+
#
|
|
1231
|
+
# @return [void]
|
|
1232
|
+
#
|
|
1233
|
+
def hydrate_from_issue(other)
|
|
1234
|
+
@github_response = other.github_response
|
|
1235
|
+
self.number = other.number
|
|
1236
|
+
self.title = other.title
|
|
1237
|
+
self.state = other.state
|
|
1238
|
+
self.body = other.attributes['body']
|
|
1239
|
+
@body_dirty = false
|
|
1240
|
+
self.raw_body = other.raw_body
|
|
1241
|
+
self.updated_at = other.updated_at
|
|
1242
|
+
self.closed_at = other.closed_at
|
|
1243
|
+
self.locked = other.locked
|
|
1244
|
+
self.labels = other.labels
|
|
1245
|
+
self.repo = other.repo
|
|
1246
|
+
self.metadata = other.metadata
|
|
1247
|
+
persisted!
|
|
1248
|
+
@comments = nil
|
|
1249
|
+
invalidate_links_cache!
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
# Formats the next reminder time as an ISO 8601 UTC string, using
|
|
1253
|
+
# per-issue +metadata.reminder_days+ when set or
|
|
1254
|
+
# +config.reminder_days+ otherwise. Returns +nil+ when the
|
|
1255
|
+
# effective schedule is empty.
|
|
1256
|
+
#
|
|
1257
|
+
# @param from [Time] baseline timestamp
|
|
1258
|
+
#
|
|
1259
|
+
# @return [String, nil]
|
|
1260
|
+
#
|
|
1261
|
+
def format_next_reminder_at(from:)
|
|
1262
|
+
days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
1263
|
+
return if days.empty?
|
|
1264
|
+
|
|
1265
|
+
(from + days.first.days).utc.iso8601
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
# Formats a +Time+ as an ISO 8601 UTC string, or +nil+ when the
|
|
1269
|
+
# input is nil.
|
|
1270
|
+
#
|
|
1271
|
+
# @param time [Time, nil]
|
|
1272
|
+
#
|
|
1273
|
+
# @return [String, nil]
|
|
1274
|
+
#
|
|
1275
|
+
def format_time(time)
|
|
1276
|
+
return if time.nil?
|
|
1277
|
+
|
|
1278
|
+
time.utc.iso8601
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
# Fires the appropriate notification event for an update: +issue.closed+
|
|
1282
|
+
# or +issue.reopened+ on a state transition, otherwise +issue.updated+
|
|
1283
|
+
# with the captured dirty-tracking diff.
|
|
1284
|
+
#
|
|
1285
|
+
# @param captured [Hash] +ActiveModel::Dirty+ changes captured before +persist_update!+
|
|
1286
|
+
# @param user [Object, nil]
|
|
1287
|
+
#
|
|
1288
|
+
# @return [void]
|
|
1289
|
+
#
|
|
1290
|
+
def instrument_update(captured, user)
|
|
1291
|
+
case captured['state']
|
|
1292
|
+
when %w[open closed]
|
|
1293
|
+
PlanMyStuff::Notifications.instrument('issue.closed', self, user: user)
|
|
1294
|
+
when %w[closed open]
|
|
1295
|
+
PlanMyStuff::Notifications.instrument('issue.reopened', self, user: user)
|
|
1296
|
+
else
|
|
1297
|
+
PlanMyStuff::Notifications.instrument('issue.updated', self, user: user, changes: captured)
|
|
1298
|
+
end
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
# When an issue is transitioning from open to closed, strips both
|
|
1302
|
+
# waiting labels from the outgoing labels array and clears the
|
|
1303
|
+
# waiting-related timestamps on +metadata+ so a single save writes
|
|
1304
|
+
# both state change and cleanup. No-op for any other transition.
|
|
1305
|
+
#
|
|
1306
|
+
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1307
|
+
# +Issue.update!+; mutated in place
|
|
1308
|
+
#
|
|
1309
|
+
# @return [void]
|
|
1310
|
+
#
|
|
1311
|
+
def clear_waiting_state_on_close!(attrs)
|
|
1312
|
+
return unless state_changed?
|
|
1313
|
+
return unless state_was == 'open'
|
|
1314
|
+
return unless state == 'closed'
|
|
1315
|
+
|
|
1316
|
+
return if metadata.waiting_on_user_at.blank? && metadata.waiting_on_approval_at.blank?
|
|
1317
|
+
|
|
1318
|
+
waiting_labels = [
|
|
1319
|
+
PlanMyStuff.configuration.waiting_on_user_label,
|
|
1320
|
+
PlanMyStuff.configuration.waiting_on_approval_label,
|
|
1321
|
+
]
|
|
1322
|
+
attrs[:labels] = Array.wrap(attrs[:labels]) - waiting_labels
|
|
1323
|
+
|
|
1324
|
+
metadata.waiting_on_user_at = nil
|
|
1325
|
+
metadata.waiting_on_approval_at = nil
|
|
1326
|
+
metadata.next_reminder_at = nil
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
# When an inactivity-closed issue is being reopened, strips the
|
|
1330
|
+
# +user_inactive_label+ from the outgoing labels and clears
|
|
1331
|
+
# +metadata.closed_by_inactivity+ so the save writes both. No-op
|
|
1332
|
+
# for any other transition or for reopens of non-inactive closes.
|
|
1333
|
+
#
|
|
1334
|
+
# @param attrs [Hash] the kwargs hash being assembled for
|
|
1335
|
+
# +Issue.update!+; mutated in place
|
|
1336
|
+
#
|
|
1337
|
+
# @return [void]
|
|
1338
|
+
#
|
|
1339
|
+
def clear_inactivity_state_on_reopen!(attrs)
|
|
1340
|
+
return unless state_changed?
|
|
1341
|
+
return unless state_was == 'closed'
|
|
1342
|
+
return unless state == 'open'
|
|
1343
|
+
return unless metadata.closed_by_inactivity
|
|
1344
|
+
|
|
1345
|
+
attrs[:labels] = Array.wrap(attrs[:labels]) - [PlanMyStuff.configuration.user_inactive_label]
|
|
1346
|
+
metadata.closed_by_inactivity = false
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
# Full-write persistence path for an already-persisted issue.
|
|
1350
|
+
# Delegates to +Issue.update!+ passing the full in-memory state
|
|
1351
|
+
# (title/state/labels plus the current +metadata+ object so the
|
|
1352
|
+
# class method serializes it authoritatively). Only passes
|
|
1353
|
+
# +body:+ when +@body_dirty+, so the PMS body comment is
|
|
1354
|
+
# rewritten exactly when +#body=+ has been called since load.
|
|
1355
|
+
#
|
|
1356
|
+
# @raise [PlanMyStuff::StaleObjectError]
|
|
1357
|
+
#
|
|
1358
|
+
# @return [void]
|
|
1359
|
+
#
|
|
1360
|
+
def persist_update!
|
|
1361
|
+
raise_if_stale!
|
|
1362
|
+
|
|
1363
|
+
attrs = {
|
|
1364
|
+
number: number,
|
|
1365
|
+
repo: repo,
|
|
1366
|
+
title: title,
|
|
1367
|
+
state: state,
|
|
1368
|
+
labels: labels || [],
|
|
1369
|
+
metadata: metadata,
|
|
1370
|
+
}
|
|
1371
|
+
attrs[:body] = body if @body_dirty
|
|
1372
|
+
attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
|
|
1373
|
+
|
|
1374
|
+
clear_waiting_state_on_close!(attrs)
|
|
1375
|
+
clear_inactivity_state_on_reopen!(attrs)
|
|
1376
|
+
|
|
1377
|
+
self.class.update!(**attrs)
|
|
1378
|
+
|
|
1379
|
+
@body_dirty = false
|
|
1380
|
+
@pending_assignees = nil
|
|
1381
|
+
reload
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
# Applies in-memory updates from an +update!+ kwargs hash.
|
|
1385
|
+
# Top-level scalars go through their setters so +@body_dirty+
|
|
1386
|
+
# and friends stay in sync; +metadata:+ is merged into
|
|
1387
|
+
# +@metadata+ (top-level attrs assigned directly, custom_fields
|
|
1388
|
+
# merged key-by-key).
|
|
1389
|
+
#
|
|
1390
|
+
# @return [void]
|
|
1391
|
+
#
|
|
1392
|
+
def apply_update_attrs!(attrs)
|
|
1393
|
+
self.title = attrs[:title] if attrs.key?(:title)
|
|
1394
|
+
self.state = attrs[:state].to_s if attrs.key?(:state)
|
|
1395
|
+
self.labels = attrs[:labels] if attrs.key?(:labels)
|
|
1396
|
+
self.body = attrs[:body] if attrs.key?(:body)
|
|
1397
|
+
@pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
|
|
1398
|
+
apply_metadata_attrs!(attrs[:metadata]) if attrs.key?(:metadata)
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
# @return [void]
|
|
1402
|
+
def apply_metadata_attrs!(md_hash)
|
|
1403
|
+
return if md_hash.nil?
|
|
1404
|
+
|
|
1405
|
+
md_hash.each do |key, value|
|
|
1406
|
+
if key == :custom_fields
|
|
1407
|
+
value.each { |k, v| metadata.custom_fields[k] = v }
|
|
1408
|
+
elsif metadata.respond_to?("#{key}=")
|
|
1409
|
+
metadata.public_send("#{key}=", value)
|
|
1410
|
+
end
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# Raises StaleObjectError if the remote issue has been modified
|
|
1415
|
+
# since this instance was loaded.
|
|
1416
|
+
#
|
|
1417
|
+
# @raise [PlanMyStuff::StaleObjectError]
|
|
1418
|
+
#
|
|
1419
|
+
# @return [void]
|
|
1420
|
+
#
|
|
1421
|
+
def raise_if_stale!
|
|
1422
|
+
return if new_record?
|
|
1423
|
+
return if updated_at.nil?
|
|
1424
|
+
|
|
1425
|
+
remote = self.class.find(number, repo: repo)
|
|
1426
|
+
remote_time = remote.updated_at
|
|
1427
|
+
local_time = updated_at
|
|
1428
|
+
|
|
1429
|
+
return if remote_time.nil?
|
|
1430
|
+
return if local_time && remote_time.to_i == local_time.to_i
|
|
1431
|
+
|
|
1432
|
+
raise(StaleObjectError.new(
|
|
1433
|
+
"Issue ##{number} has been modified remotely",
|
|
1434
|
+
local_updated_at: local_time,
|
|
1435
|
+
remote_updated_at: remote_time,
|
|
1436
|
+
))
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
# @return [Array<String>]
|
|
1440
|
+
def extract_labels(github_issue)
|
|
1441
|
+
raw = read_field(github_issue, :labels) || []
|
|
1442
|
+
raw.map { |label| label_name(label) }
|
|
1443
|
+
end
|
|
1444
|
+
|
|
1445
|
+
# @return [String]
|
|
1446
|
+
def label_name(label)
|
|
1447
|
+
return label.name if label.respond_to?(:name)
|
|
1448
|
+
return label[:name] || label['name'] if label.is_a?(Hash)
|
|
1449
|
+
|
|
1450
|
+
label.to_s
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
# @return [Array<PlanMyStuff::Comment>]
|
|
1454
|
+
def load_comments
|
|
1455
|
+
Comment.list(issue: self)
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
# @return [Hash{Symbol => Array}]
|
|
1459
|
+
def links_cache
|
|
1460
|
+
@links_cache ||= {}
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# Clears all memoized link readers. Called from +#hydrate_from_github+
|
|
1464
|
+
# and after any successful write.
|
|
1465
|
+
#
|
|
1466
|
+
# @return [void]
|
|
1467
|
+
#
|
|
1468
|
+
def invalidate_links_cache!
|
|
1469
|
+
@links_cache = {}
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source
|
|
1473
|
+
# repo defaulting to self's repo.
|
|
1474
|
+
#
|
|
1475
|
+
# @return [PlanMyStuff::Link]
|
|
1476
|
+
#
|
|
1477
|
+
def build_link(target, type:)
|
|
1478
|
+
PlanMyStuff::Link.build(target, type: type, source_repo: repo&.full_name)
|
|
1479
|
+
end
|
|
1480
|
+
|
|
1481
|
+
# @raise [PlanMyStuff::ValidationError]
|
|
1482
|
+
# @return [void]
|
|
1483
|
+
#
|
|
1484
|
+
def validate_not_self!(link)
|
|
1485
|
+
return if link.issue_number != number
|
|
1486
|
+
return unless link.same_repo?(repo)
|
|
1487
|
+
|
|
1488
|
+
raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
|
|
1489
|
+
end
|
|
1490
|
+
|
|
1491
|
+
# Reads +metadata.links+ and coerces any legacy hash entries to
|
|
1492
|
+
# +Link+ instances. Invalid entries are dropped.
|
|
1493
|
+
#
|
|
1494
|
+
# @return [Array<PlanMyStuff::Link>]
|
|
1495
|
+
#
|
|
1496
|
+
def current_links
|
|
1497
|
+
metadata.links.filter_map do |entry|
|
|
1498
|
+
next entry if entry.is_a?(PlanMyStuff::Link)
|
|
1499
|
+
|
|
1500
|
+
PlanMyStuff::Link.build(entry)
|
|
1501
|
+
rescue ActiveModel::ValidationError, ArgumentError
|
|
1502
|
+
next
|
|
1503
|
+
end
|
|
1504
|
+
end
|
|
1505
|
+
|
|
1506
|
+
# Writes the given link array back to GitHub via
|
|
1507
|
+
# +Issue.update!+ and updates local metadata so subsequent
|
|
1508
|
+
# in-memory reads see the change without a +reload+.
|
|
1509
|
+
#
|
|
1510
|
+
# @param new_links [Array<PlanMyStuff::Link>]
|
|
1511
|
+
#
|
|
1512
|
+
# @return [void]
|
|
1513
|
+
#
|
|
1514
|
+
def persist_links!(new_links)
|
|
1515
|
+
self.class.update!(
|
|
1516
|
+
number: number,
|
|
1517
|
+
repo: repo,
|
|
1518
|
+
metadata: { links: new_links.map(&:to_h) },
|
|
1519
|
+
)
|
|
1520
|
+
metadata.links = new_links
|
|
1521
|
+
invalidate_links_cache!
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
# Walks every Projects V2 board this issue sits on and deletes the
|
|
1525
|
+
# corresponding item. Paginates via +LIST_ISSUE_PROJECT_ITEMS+ with
|
|
1526
|
+
# a safety cap to avoid runaway loops. Delete failures propagate.
|
|
1527
|
+
#
|
|
1528
|
+
# @return [void]
|
|
1529
|
+
#
|
|
1530
|
+
def remove_from_all_projects!
|
|
1531
|
+
client = PlanMyStuff.client
|
|
1532
|
+
owner = repo.organization
|
|
1533
|
+
repo_name = repo.name
|
|
1534
|
+
cursor = nil
|
|
1535
|
+
|
|
1536
|
+
10.times do
|
|
1537
|
+
data = client.graphql(
|
|
1538
|
+
PlanMyStuff::GraphQL::Queries::LIST_ISSUE_PROJECT_ITEMS,
|
|
1539
|
+
variables: { owner: owner, repo: repo_name, number: number, cursor: cursor },
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
connection = data.dig(:repository, :issue, :projectItems) || {}
|
|
1543
|
+
nodes = Array.wrap(connection[:nodes])
|
|
1544
|
+
|
|
1545
|
+
nodes.each do |node|
|
|
1546
|
+
PlanMyStuff::ProjectItem.delete_item(
|
|
1547
|
+
item_id: node[:id],
|
|
1548
|
+
project_number: node.dig(:project, :number),
|
|
1549
|
+
)
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
page_info = connection[:pageInfo] || {}
|
|
1553
|
+
break unless page_info[:hasNextPage]
|
|
1554
|
+
|
|
1555
|
+
cursor = page_info[:endCursor]
|
|
1556
|
+
end
|
|
1557
|
+
end
|
|
1558
|
+
|
|
1559
|
+
# Attempts the reciprocal write on +link+'s target. On failure,
|
|
1560
|
+
# fires +plan_my_stuff.issue.link_reciprocal_failed+ so the
|
|
1561
|
+
# consuming app can surface the half-written pairing.
|
|
1562
|
+
#
|
|
1563
|
+
# @param link [PlanMyStuff::Link]
|
|
1564
|
+
# @param user [Object, nil]
|
|
1565
|
+
#
|
|
1566
|
+
# @return [void]
|
|
1567
|
+
#
|
|
1568
|
+
def mirror_on_target(link, user:)
|
|
1569
|
+
target = PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1570
|
+
yield(target)
|
|
1571
|
+
rescue PlanMyStuff::Error, Octokit::Error => e
|
|
1572
|
+
PlanMyStuff::Notifications.instrument(
|
|
1573
|
+
'issue.link_reciprocal_failed',
|
|
1574
|
+
self,
|
|
1575
|
+
user: user,
|
|
1576
|
+
link: link,
|
|
1577
|
+
error: e.message,
|
|
1578
|
+
)
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
1582
|
+
def fetch_related
|
|
1583
|
+
current_links.filter_map do |link|
|
|
1584
|
+
next unless link.type == 'related'
|
|
1585
|
+
|
|
1586
|
+
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1587
|
+
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
1588
|
+
next
|
|
1589
|
+
end
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
1593
|
+
def fetch_parent
|
|
1594
|
+
response = PlanMyStuff.client.rest(:get, parent_path)
|
|
1595
|
+
return if response.blank?
|
|
1596
|
+
|
|
1597
|
+
parent_number = response.respond_to?(:number) ? response.number : response[:number]
|
|
1598
|
+
PlanMyStuff::Issue.find(parent_number, repo: repo)
|
|
1599
|
+
rescue PlanMyStuff::APIError => e
|
|
1600
|
+
return if e.status == 404
|
|
1601
|
+
|
|
1602
|
+
raise
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
1606
|
+
def fetch_sub_tickets
|
|
1607
|
+
response = PlanMyStuff.client.rest(:get, sub_issues_path)
|
|
1608
|
+
Array.wrap(response).filter_map do |row|
|
|
1609
|
+
sub_number = row.respond_to?(:number) ? row.number : row[:number]
|
|
1610
|
+
PlanMyStuff::Issue.find(sub_number, repo: repo)
|
|
1611
|
+
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
1612
|
+
next
|
|
1613
|
+
end
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when
|
|
1617
|
+
# we only have a +Link+ or hash). Used by +set_parent!+ /
|
|
1618
|
+
# +remove_parent!+ to invert the call back through
|
|
1619
|
+
# +#add_sub_issue!+ / +#remove_sub_issue!+ on the parent side.
|
|
1620
|
+
#
|
|
1621
|
+
# @return [PlanMyStuff::Issue]
|
|
1622
|
+
#
|
|
1623
|
+
def resolve_target_issue(target, type:)
|
|
1624
|
+
return target if target.is_a?(PlanMyStuff::Issue)
|
|
1625
|
+
|
|
1626
|
+
link = build_link(target, type: type)
|
|
1627
|
+
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1628
|
+
end
|
|
1629
|
+
|
|
1630
|
+
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the
|
|
1631
|
+
# link, resolves the target, runs the mutation, busts caches.
|
|
1632
|
+
#
|
|
1633
|
+
# @return [PlanMyStuff::Link]
|
|
1634
|
+
#
|
|
1635
|
+
def mutate_sub_issue!(target, method:, path:)
|
|
1636
|
+
link = build_link(target, type: :sub_ticket)
|
|
1637
|
+
validate_not_self!(link)
|
|
1638
|
+
|
|
1639
|
+
target_issue = resolve_target_issue(target, type: :sub_ticket)
|
|
1640
|
+
PlanMyStuff.client.rest(
|
|
1641
|
+
method,
|
|
1642
|
+
path,
|
|
1643
|
+
{ sub_issue_id: target_issue.__send__(:require_github_id!) },
|
|
1644
|
+
)
|
|
1645
|
+
invalidate_links_cache!
|
|
1646
|
+
link
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
# @return [String]
|
|
1650
|
+
def parent_path
|
|
1651
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/parent"
|
|
1652
|
+
end
|
|
1653
|
+
|
|
1654
|
+
# @return [String]
|
|
1655
|
+
def sub_issues_path
|
|
1656
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct
|
|
1660
|
+
# from the list/add path +/sub_issues+ (plural).
|
|
1661
|
+
#
|
|
1662
|
+
# @return [String]
|
|
1663
|
+
#
|
|
1664
|
+
def remove_sub_issue_path
|
|
1665
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
|
|
1666
|
+
end
|
|
1667
|
+
|
|
1668
|
+
# Fetches one side of the native issue-dependency graph for self
|
|
1669
|
+
# (+blocked_by+ or +blocking+) via REST. Response is an array of
|
|
1670
|
+
# Issue objects; we map through +Issue.find+ to get fully hydrated
|
|
1671
|
+
# instances (the dependency endpoint returns a slim projection).
|
|
1672
|
+
#
|
|
1673
|
+
# @param side [String] "blocked_by" or "blocking"
|
|
1674
|
+
#
|
|
1675
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
1676
|
+
#
|
|
1677
|
+
def fetch_dependencies(side)
|
|
1678
|
+
response = PlanMyStuff.client.rest(:get, dependency_path(side))
|
|
1679
|
+
Array.wrap(response).filter_map do |row|
|
|
1680
|
+
number = row.respond_to?(:number) ? row.number : row[:number]
|
|
1681
|
+
PlanMyStuff::Issue.find(number, repo: repo)
|
|
1682
|
+
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
1683
|
+
next
|
|
1684
|
+
end
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
# @return [String]
|
|
1688
|
+
def dependency_path(side)
|
|
1689
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/dependencies/#{side}"
|
|
1690
|
+
end
|
|
1691
|
+
|
|
1692
|
+
# @raise [PlanMyStuff::Error]
|
|
1693
|
+
# @return [Integer]
|
|
1694
|
+
#
|
|
1695
|
+
def require_github_id!
|
|
1696
|
+
id = github_id
|
|
1697
|
+
raise(PlanMyStuff::Error, "Issue ##{number} has no database id; cannot run native REST mutation") if id.nil?
|
|
1698
|
+
|
|
1699
|
+
id
|
|
1700
|
+
end
|
|
1701
|
+
|
|
1702
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
1703
|
+
def fetch_duplicate_of
|
|
1704
|
+
data = PlanMyStuff.client.graphql(
|
|
1705
|
+
PlanMyStuff::GraphQL::Queries::FETCH_DUPLICATE_OF,
|
|
1706
|
+
variables: { owner: repo.organization, repo: repo.name, number: number },
|
|
1707
|
+
)
|
|
1708
|
+
issue_data = data.dig(:repository, :issue) || {}
|
|
1709
|
+
return unless issue_data[:stateReason].to_s.casecmp?('DUPLICATE')
|
|
1710
|
+
|
|
1711
|
+
the_dupe = issue_data[:duplicateOf]
|
|
1712
|
+
return if the_dupe.blank?
|
|
1713
|
+
|
|
1714
|
+
PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
|
|
1715
|
+
end
|
|
1716
|
+
|
|
1717
|
+
# Resolves +target+ to an +Issue+ and raises +ValidationError+
|
|
1718
|
+
# when the target cannot be found.
|
|
1719
|
+
#
|
|
1720
|
+
# @return [PlanMyStuff::Issue]
|
|
1721
|
+
#
|
|
1722
|
+
def resolve_duplicate_target!(target)
|
|
1723
|
+
resolve_target_issue(target, type: :duplicate_of)
|
|
1724
|
+
rescue Octokit::NotFound, PlanMyStuff::APIError => e
|
|
1725
|
+
raise(PlanMyStuff::ValidationError, "Duplicate target not found: #{e.message}")
|
|
1726
|
+
end
|
|
1727
|
+
|
|
1728
|
+
# Unions self's visibility_allowlist onto +target+'s.
|
|
1729
|
+
#
|
|
1730
|
+
# @return [void]
|
|
1731
|
+
#
|
|
1732
|
+
def merge_visibility_allowlist_onto(target)
|
|
1733
|
+
return if metadata.visibility_allowlist.blank?
|
|
1734
|
+
|
|
1735
|
+
merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
|
|
1736
|
+
target.update!(metadata: { visibility_allowlist: merged })
|
|
1737
|
+
end
|
|
1738
|
+
|
|
1739
|
+
# Unions self's GitHub assignees (by login) onto +target+'s.
|
|
1740
|
+
#
|
|
1741
|
+
# @return [void]
|
|
1742
|
+
#
|
|
1743
|
+
def merge_assignees_onto(target)
|
|
1744
|
+
source_logins = extract_assignee_logins(github_response)
|
|
1745
|
+
return if source_logins.empty?
|
|
1746
|
+
|
|
1747
|
+
merged = extract_assignee_logins(target.github_response) | source_logins
|
|
1748
|
+
target.update!(assignees: merged)
|
|
1749
|
+
end
|
|
1750
|
+
|
|
1751
|
+
# @param response [Object] Octokit issue response
|
|
1752
|
+
#
|
|
1753
|
+
# @return [Array<String>]
|
|
1754
|
+
#
|
|
1755
|
+
def extract_assignee_logins(response)
|
|
1756
|
+
raw = safe_read_field(response, :assignees) || []
|
|
1757
|
+
raw.filter_map { |a| a.respond_to?(:login) ? a.login : a[:login] || a['login'] }
|
|
1758
|
+
end
|
|
1759
|
+
|
|
1760
|
+
# @return [void]
|
|
1761
|
+
def post_duplicate_back_pointer(target, user:)
|
|
1762
|
+
visibility = target.metadata.visibility.presence || 'public'
|
|
1763
|
+
PlanMyStuff::Comment.create!(
|
|
1764
|
+
issue: target,
|
|
1765
|
+
body: "Marked duplicate of this by #{repo.full_name}##{number}",
|
|
1766
|
+
user: user,
|
|
1767
|
+
visibility: visibility.to_sym,
|
|
1768
|
+
)
|
|
1769
|
+
end
|
|
1770
|
+
|
|
1771
|
+
# Closes self as a duplicate of +target+ via GitHub's native
|
|
1772
|
+
# +closeIssue+ GraphQL mutation with +stateReason: DUPLICATE+ and
|
|
1773
|
+
# +duplicateIssueId+. The REST +duplicate_of+ body param is not
|
|
1774
|
+
# recognized; only this GraphQL path actually wires up
|
|
1775
|
+
# +Issue#duplicateOf+ on the closed issue.
|
|
1776
|
+
#
|
|
1777
|
+
# @return [void]
|
|
1778
|
+
#
|
|
1779
|
+
def close_as_duplicate!(target)
|
|
1780
|
+
source_node_id = github_node_id
|
|
1781
|
+
target_node_id = target.github_node_id
|
|
1782
|
+
raise(PlanMyStuff::Error, "Issue ##{number} has no node_id") if source_node_id.blank?
|
|
1783
|
+
raise(PlanMyStuff::Error, "Target issue ##{target.number} has no node_id") if target_node_id.blank?
|
|
1784
|
+
|
|
1785
|
+
PlanMyStuff.client.graphql(
|
|
1786
|
+
PlanMyStuff::GraphQL::Queries::CLOSE_AS_DUPLICATE,
|
|
1787
|
+
variables: { issueId: source_node_id, duplicateIssueId: target_node_id },
|
|
1788
|
+
)
|
|
488
1789
|
end
|
|
489
1790
|
end
|
|
490
1791
|
end
|