plan_my_stuff 0.8.0 → 0.10.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 +31 -0
- data/CONFIGURATION.md +351 -0
- data/app/views/plan_my_stuff/issues/show.html.erb +1 -1
- data/app/views/plan_my_stuff/partials/_flash.html.erb +0 -1
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +1 -12
- data/lib/plan_my_stuff/base_project.rb +5 -176
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +184 -0
- data/lib/plan_my_stuff/base_project_item.rb +1 -0
- data/lib/plan_my_stuff/comment.rb +5 -3
- data/lib/plan_my_stuff/configuration.rb +3 -16
- data/lib/plan_my_stuff/issue.rb +15 -1082
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +148 -0
- data/lib/plan_my_stuff/label.rb +4 -4
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/tasks/plan_my_stuff.rake +2 -2
- metadata +8 -2
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module IssueExtractions
|
|
5
|
+
module Links
|
|
6
|
+
# Lazy-memoized array of +Issue+ objects for +:related+ links. Silently drops targets that 404 so a dangling
|
|
7
|
+
# pointer doesn't break the rest of the list.
|
|
8
|
+
#
|
|
9
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
10
|
+
#
|
|
11
|
+
def related
|
|
12
|
+
links_cache[:related] ||= fetch_related
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Adds a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the link back on
|
|
16
|
+
# +target+ so the pairing is symmetric. Dedups on +(type, issue_number, repo)+ - re-adding is a no-op.
|
|
17
|
+
#
|
|
18
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
19
|
+
# @param user [Object, nil] actor for notification events
|
|
20
|
+
# @param reciprocal [Boolean] internal flag; set by the mirror call
|
|
21
|
+
#
|
|
22
|
+
# @return [PlanMyStuff::Link]
|
|
23
|
+
#
|
|
24
|
+
def add_related!(target, user: nil, reciprocal: false)
|
|
25
|
+
link = build_link!(target, type: :related)
|
|
26
|
+
validate_not_self!(link)
|
|
27
|
+
|
|
28
|
+
existing = current_links
|
|
29
|
+
return link if existing.include?(link)
|
|
30
|
+
|
|
31
|
+
persist_links!(existing + [link])
|
|
32
|
+
unless reciprocal
|
|
33
|
+
mirror_on_target(link, user: user) { |other| other.add_related!(self, user: user, reciprocal: true) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
link
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Removes a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the removal on
|
|
40
|
+
# +target+. No-op when the link isn't present locally.
|
|
41
|
+
#
|
|
42
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
43
|
+
# @param user [Object, nil]
|
|
44
|
+
# @param reciprocal [Boolean]
|
|
45
|
+
#
|
|
46
|
+
# @return [PlanMyStuff::Link]
|
|
47
|
+
#
|
|
48
|
+
def remove_related!(target, user: nil, reciprocal: false)
|
|
49
|
+
link = build_link!(target, type: :related)
|
|
50
|
+
validate_not_self!(link)
|
|
51
|
+
|
|
52
|
+
existing = current_links
|
|
53
|
+
return link if existing.exclude?(link)
|
|
54
|
+
|
|
55
|
+
persist_links!(existing.reject { |l| l == link })
|
|
56
|
+
unless reciprocal
|
|
57
|
+
mirror_on_target(link, user: user) { |other| other.remove_related!(self, user: user, reciprocal: true) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
link
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Lazy-memoized parent issue via GitHub's native sub-issues API. GitHub enforces at most one parent per issue.
|
|
64
|
+
#
|
|
65
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
66
|
+
#
|
|
67
|
+
def parent
|
|
68
|
+
return links_cache[:parent] if links_cache.key?(:parent)
|
|
69
|
+
|
|
70
|
+
links_cache[:parent] = fetch_parent
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Lazy-memoized sub-issues via GitHub's native sub-issues API.
|
|
74
|
+
#
|
|
75
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
76
|
+
#
|
|
77
|
+
def sub_tickets
|
|
78
|
+
links_cache[:sub_tickets] ||= fetch_sub_tickets
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Adds +target+ as a sub-issue of self via +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
82
|
+
# notifications are handled by GitHub itself.
|
|
83
|
+
#
|
|
84
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
85
|
+
#
|
|
86
|
+
# @return [PlanMyStuff::Link]
|
|
87
|
+
#
|
|
88
|
+
def add_sub_issue!(target)
|
|
89
|
+
mutate_sub_issue!(target, method: :post, path: sub_issues_path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Removes +target+ as a sub-issue of self via +DELETE /issues/{number}/sub_issue+ (singular).
|
|
93
|
+
#
|
|
94
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
95
|
+
#
|
|
96
|
+
# @return [PlanMyStuff::Link]
|
|
97
|
+
#
|
|
98
|
+
def remove_sub_issue!(target)
|
|
99
|
+
mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Makes +target+ the parent of self. If self already has a parent, it is detached first. Returns a +Link+
|
|
103
|
+
# describing the new +:parent+ relationship.
|
|
104
|
+
#
|
|
105
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
106
|
+
#
|
|
107
|
+
# @return [PlanMyStuff::Link]
|
|
108
|
+
#
|
|
109
|
+
def set_parent!(target)
|
|
110
|
+
parent.presence&.remove_sub_issue!(self)
|
|
111
|
+
|
|
112
|
+
target_issue = resolve_target_issue(target, type: :parent)
|
|
113
|
+
target_issue.add_sub_issue!(self)
|
|
114
|
+
invalidate_links_cache!
|
|
115
|
+
|
|
116
|
+
build_link!(target_issue, type: :parent)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Detaches self from its current parent, if any. Returns the +Link+ that was removed, or nil when there was no
|
|
120
|
+
# parent.
|
|
121
|
+
#
|
|
122
|
+
# @return [PlanMyStuff::Link, nil]
|
|
123
|
+
#
|
|
124
|
+
def remove_parent!
|
|
125
|
+
current = parent
|
|
126
|
+
return if current.nil?
|
|
127
|
+
|
|
128
|
+
current.remove_sub_issue!(self)
|
|
129
|
+
invalidate_links_cache!
|
|
130
|
+
|
|
131
|
+
build_link!(current, type: :parent)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub's native
|
|
135
|
+
# issue-dependency REST API.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
138
|
+
#
|
|
139
|
+
def blocked_by
|
|
140
|
+
links_cache[:blocked_by] ||= fetch_dependencies('blocked_by')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Lazy-memoized issues that self blocks.
|
|
144
|
+
#
|
|
145
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
146
|
+
#
|
|
147
|
+
def blocking
|
|
148
|
+
links_cache[:blocking] ||= fetch_dependencies('blocking')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Records that +target+ blocks self. Native GitHub action; notifications are handled by GitHub itself.
|
|
152
|
+
#
|
|
153
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
154
|
+
#
|
|
155
|
+
# @return [PlanMyStuff::Link]
|
|
156
|
+
#
|
|
157
|
+
def add_blocker!(target)
|
|
158
|
+
link = build_link!(target, type: :blocked_by)
|
|
159
|
+
validate_not_self!(link)
|
|
160
|
+
|
|
161
|
+
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
162
|
+
PlanMyStuff.client.rest(
|
|
163
|
+
:post,
|
|
164
|
+
dependency_path('blocked_by'),
|
|
165
|
+
{ issue_id: target_issue.__send__(:require_github_id!) },
|
|
166
|
+
)
|
|
167
|
+
invalidate_links_cache!
|
|
168
|
+
link
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Removes the record that +target+ blocks self.
|
|
172
|
+
#
|
|
173
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
174
|
+
#
|
|
175
|
+
# @return [PlanMyStuff::Link]
|
|
176
|
+
#
|
|
177
|
+
def remove_blocker!(target)
|
|
178
|
+
link = build_link!(target, type: :blocked_by)
|
|
179
|
+
validate_not_self!(link)
|
|
180
|
+
|
|
181
|
+
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
182
|
+
PlanMyStuff.client.rest(
|
|
183
|
+
:delete,
|
|
184
|
+
"#{dependency_path('blocked_by')}/#{target_issue.__send__(:require_github_id!)}",
|
|
185
|
+
)
|
|
186
|
+
invalidate_links_cache!
|
|
187
|
+
link
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Lazy-memoized issue that self was marked as duplicate of, via GitHub's native close-as-duplicate. Returns nil
|
|
191
|
+
# for issues that are open or closed for other reasons.
|
|
192
|
+
#
|
|
193
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
194
|
+
#
|
|
195
|
+
def duplicate_of
|
|
196
|
+
return links_cache[:duplicate_of] if links_cache.key?(:duplicate_of)
|
|
197
|
+
|
|
198
|
+
links_cache[:duplicate_of] = fetch_duplicate_of
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Closes self as a duplicate of +target+ via GitHub's native close-as-duplicate, carrying over viewers,
|
|
202
|
+
# assignees, and a back-pointer comment on the target.
|
|
203
|
+
#
|
|
204
|
+
# Side effects, in order:
|
|
205
|
+
# 1. Resolves +target+; raises +ValidationError+ if missing.
|
|
206
|
+
# 2. Raises +ValidationError+ when self is already closed.
|
|
207
|
+
# 3. Merges self's +visibility_allowlist+ onto target.
|
|
208
|
+
# 4. Merges self's assignees onto target.
|
|
209
|
+
# 5. Posts a PMS comment on target with the back-pointer.
|
|
210
|
+
# 6. Closes self with +state_reason: :duplicate+ and
|
|
211
|
+
# +duplicate_of: { owner:, repo:, number: }+.
|
|
212
|
+
# 7. Reloads self; invalidates link caches.
|
|
213
|
+
# 8. Fires +plan_my_stuff.issue.marked_duplicate+.
|
|
214
|
+
#
|
|
215
|
+
# Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.
|
|
216
|
+
#
|
|
217
|
+
# @raise [PlanMyStuff::ValidationError] when the issue is already closed
|
|
218
|
+
#
|
|
219
|
+
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
220
|
+
# @param user [Object, nil] actor for notification + comment
|
|
221
|
+
#
|
|
222
|
+
# @return [PlanMyStuff::Link]
|
|
223
|
+
#
|
|
224
|
+
def mark_duplicate!(target, user: nil)
|
|
225
|
+
raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
|
|
226
|
+
|
|
227
|
+
target_issue = resolve_duplicate_target!(target)
|
|
228
|
+
merge_visibility_allowlist_onto!(target_issue)
|
|
229
|
+
merge_assignees_onto!(target_issue)
|
|
230
|
+
post_duplicate_back_pointer!(target_issue, user: user)
|
|
231
|
+
close_as_duplicate!(target_issue)
|
|
232
|
+
|
|
233
|
+
reload
|
|
234
|
+
invalidate_links_cache!
|
|
235
|
+
PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
|
|
236
|
+
|
|
237
|
+
build_link!(target_issue, type: :duplicate_of)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
# @return [Hash{Symbol => Array}]
|
|
243
|
+
def links_cache
|
|
244
|
+
@links_cache ||= {}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Clears all memoized link readers. Called from +#hydrate_from_github+ and after any successful write.
|
|
248
|
+
#
|
|
249
|
+
# @return [void]
|
|
250
|
+
#
|
|
251
|
+
def invalidate_links_cache!
|
|
252
|
+
@links_cache = {}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source repo defaulting to self's repo.
|
|
256
|
+
#
|
|
257
|
+
# @return [PlanMyStuff::Link]
|
|
258
|
+
#
|
|
259
|
+
def build_link!(target, type:)
|
|
260
|
+
PlanMyStuff::Link.build!(target, type: type, source_repo: repo&.full_name)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @raise [PlanMyStuff::ValidationError] when target is the same issue (self-link)
|
|
264
|
+
#
|
|
265
|
+
# @return [void]
|
|
266
|
+
#
|
|
267
|
+
def validate_not_self!(link)
|
|
268
|
+
return if link.issue_number != number
|
|
269
|
+
return unless link.same_repo?(repo)
|
|
270
|
+
|
|
271
|
+
raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Reads +metadata.links+ and coerces any legacy hash entries to +Link+ instances. Invalid entries are dropped.
|
|
275
|
+
#
|
|
276
|
+
# @return [Array<PlanMyStuff::Link>]
|
|
277
|
+
#
|
|
278
|
+
def current_links
|
|
279
|
+
metadata.links.filter_map do |entry|
|
|
280
|
+
next entry if entry.is_a?(PlanMyStuff::Link)
|
|
281
|
+
|
|
282
|
+
PlanMyStuff::Link.build!(entry)
|
|
283
|
+
rescue ActiveModel::ValidationError, ArgumentError
|
|
284
|
+
next
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Writes the given link array back to GitHub via +Issue.update!+ and updates local metadata so subsequent
|
|
289
|
+
# in-memory reads see the change without a +reload+.
|
|
290
|
+
#
|
|
291
|
+
# @param new_links [Array<PlanMyStuff::Link>]
|
|
292
|
+
#
|
|
293
|
+
# @return [void]
|
|
294
|
+
#
|
|
295
|
+
def persist_links!(new_links)
|
|
296
|
+
self.class.update!(
|
|
297
|
+
number: number,
|
|
298
|
+
repo: repo,
|
|
299
|
+
metadata: { links: new_links.map(&:to_h) },
|
|
300
|
+
)
|
|
301
|
+
metadata.links = new_links
|
|
302
|
+
invalidate_links_cache!
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Attempts the reciprocal write on +link+'s target. On failure, fires
|
|
306
|
+
# +plan_my_stuff.issue.link_reciprocal_failed+ so the consuming app can surface the half-written pairing.
|
|
307
|
+
#
|
|
308
|
+
# @param link [PlanMyStuff::Link]
|
|
309
|
+
# @param user [Object, nil]
|
|
310
|
+
#
|
|
311
|
+
# @return [void]
|
|
312
|
+
#
|
|
313
|
+
def mirror_on_target(link, user:)
|
|
314
|
+
target = PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
315
|
+
yield(target)
|
|
316
|
+
rescue PlanMyStuff::Error, Octokit::Error => e
|
|
317
|
+
PlanMyStuff::Notifications.instrument(
|
|
318
|
+
'issue.link_reciprocal_failed',
|
|
319
|
+
self,
|
|
320
|
+
user: user,
|
|
321
|
+
link: link,
|
|
322
|
+
error: e.message,
|
|
323
|
+
)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
327
|
+
def fetch_related
|
|
328
|
+
current_links.filter_map do |link|
|
|
329
|
+
next unless link.type == 'related'
|
|
330
|
+
|
|
331
|
+
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
332
|
+
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
333
|
+
next
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
338
|
+
def fetch_parent
|
|
339
|
+
response = PlanMyStuff.client.rest(:get, parent_path)
|
|
340
|
+
return if response.blank?
|
|
341
|
+
|
|
342
|
+
parent_number = response.respond_to?(:number) ? response.number : response[:number]
|
|
343
|
+
PlanMyStuff::Issue.find(parent_number, repo: repo)
|
|
344
|
+
rescue PlanMyStuff::APIError => e
|
|
345
|
+
return if e.status == 404
|
|
346
|
+
|
|
347
|
+
raise
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
351
|
+
def fetch_sub_tickets
|
|
352
|
+
response = PlanMyStuff.client.rest(:get, sub_issues_path)
|
|
353
|
+
Array.wrap(response).filter_map do |row|
|
|
354
|
+
sub_number = row.respond_to?(:number) ? row.number : row[:number]
|
|
355
|
+
PlanMyStuff::Issue.find(sub_number, repo: repo)
|
|
356
|
+
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when we only have a +Link+ or hash). Used by
|
|
362
|
+
# +set_parent!+ / +remove_parent!+ to invert the call back through +#add_sub_issue!+ / +#remove_sub_issue!+ on
|
|
363
|
+
# the parent side.
|
|
364
|
+
#
|
|
365
|
+
# @return [PlanMyStuff::Issue]
|
|
366
|
+
#
|
|
367
|
+
def resolve_target_issue(target, type:)
|
|
368
|
+
return target if target.is_a?(PlanMyStuff::Issue)
|
|
369
|
+
|
|
370
|
+
link = build_link!(target, type: type)
|
|
371
|
+
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the link, resolves the target, runs the
|
|
375
|
+
# mutation, busts caches.
|
|
376
|
+
#
|
|
377
|
+
# @return [PlanMyStuff::Link]
|
|
378
|
+
#
|
|
379
|
+
def mutate_sub_issue!(target, method:, path:)
|
|
380
|
+
link = build_link!(target, type: :sub_ticket)
|
|
381
|
+
validate_not_self!(link)
|
|
382
|
+
|
|
383
|
+
target_issue = resolve_target_issue(target, type: :sub_ticket)
|
|
384
|
+
PlanMyStuff.client.rest(
|
|
385
|
+
method,
|
|
386
|
+
path,
|
|
387
|
+
{ sub_issue_id: target_issue.__send__(:require_github_id!) },
|
|
388
|
+
)
|
|
389
|
+
invalidate_links_cache!
|
|
390
|
+
link
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# @return [String]
|
|
394
|
+
def parent_path
|
|
395
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/parent"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# @return [String]
|
|
399
|
+
def sub_issues_path
|
|
400
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct from the list/add path +/sub_issues+ (plural).
|
|
404
|
+
#
|
|
405
|
+
# @return [String]
|
|
406
|
+
#
|
|
407
|
+
def remove_sub_issue_path
|
|
408
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Fetches one side of the native issue-dependency graph for self (+blocked_by+ or +blocking+) via REST.
|
|
412
|
+
# Response is an array of Issue objects; we map through +Issue.find+ to get fully hydrated instances (the
|
|
413
|
+
# dependency endpoint returns a slim projection).
|
|
414
|
+
#
|
|
415
|
+
# @param side [String] "blocked_by" or "blocking"
|
|
416
|
+
#
|
|
417
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
418
|
+
#
|
|
419
|
+
def fetch_dependencies(side)
|
|
420
|
+
response = PlanMyStuff.client.rest(:get, dependency_path(side))
|
|
421
|
+
Array.wrap(response).filter_map do |row|
|
|
422
|
+
number = row.respond_to?(:number) ? row.number : row[:number]
|
|
423
|
+
PlanMyStuff::Issue.find(number, repo: repo)
|
|
424
|
+
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
425
|
+
next
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @return [String]
|
|
430
|
+
def dependency_path(side)
|
|
431
|
+
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/dependencies/#{side}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
435
|
+
def fetch_duplicate_of
|
|
436
|
+
data = PlanMyStuff.client.graphql(
|
|
437
|
+
PlanMyStuff::GraphQL::Queries::FETCH_DUPLICATE_OF,
|
|
438
|
+
variables: { owner: repo.organization, repo: repo.name, number: number },
|
|
439
|
+
)
|
|
440
|
+
issue_data = data.dig(:repository, :issue) || {}
|
|
441
|
+
return unless issue_data[:stateReason].to_s.casecmp?('DUPLICATE')
|
|
442
|
+
|
|
443
|
+
the_dupe = issue_data[:duplicateOf]
|
|
444
|
+
return if the_dupe.blank?
|
|
445
|
+
|
|
446
|
+
PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Resolves +target+ to an +Issue+.
|
|
450
|
+
#
|
|
451
|
+
# @raise [PlanMyStuff::ValidationError] when the duplicate target cannot be found
|
|
452
|
+
#
|
|
453
|
+
# @return [PlanMyStuff::Issue]
|
|
454
|
+
#
|
|
455
|
+
def resolve_duplicate_target!(target)
|
|
456
|
+
resolve_target_issue(target, type: :duplicate_of)
|
|
457
|
+
rescue Octokit::NotFound, PlanMyStuff::APIError => e
|
|
458
|
+
raise(PlanMyStuff::ValidationError, "Duplicate target not found: #{e.message}")
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Unions self's visibility_allowlist onto +target+'s.
|
|
462
|
+
#
|
|
463
|
+
# @return [void]
|
|
464
|
+
#
|
|
465
|
+
def merge_visibility_allowlist_onto!(target)
|
|
466
|
+
return if metadata.visibility_allowlist.blank?
|
|
467
|
+
|
|
468
|
+
merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
|
|
469
|
+
target.update!(metadata: { visibility_allowlist: merged })
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Unions self's GitHub assignees (by login) onto +target+'s.
|
|
473
|
+
#
|
|
474
|
+
# @return [void]
|
|
475
|
+
#
|
|
476
|
+
def merge_assignees_onto!(target)
|
|
477
|
+
source_logins = extract_assignee_logins(github_response)
|
|
478
|
+
return if source_logins.empty?
|
|
479
|
+
|
|
480
|
+
merged = extract_assignee_logins(target.github_response) | source_logins
|
|
481
|
+
target.update!(assignees: merged)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# @param response [Object] Octokit issue response
|
|
485
|
+
#
|
|
486
|
+
# @return [Array<String>]
|
|
487
|
+
#
|
|
488
|
+
def extract_assignee_logins(response)
|
|
489
|
+
raw = safe_read_field(response, :assignees) || []
|
|
490
|
+
raw.filter_map { |a| a.respond_to?(:login) ? a.login : a[:login] || a['login'] }
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# @return [void]
|
|
494
|
+
def post_duplicate_back_pointer!(target, user:)
|
|
495
|
+
visibility = target.metadata.visibility.presence || 'public'
|
|
496
|
+
PlanMyStuff::Comment.create!(
|
|
497
|
+
issue: target,
|
|
498
|
+
body: "Marked duplicate of this by #{repo.full_name}##{number}",
|
|
499
|
+
user: user,
|
|
500
|
+
visibility: visibility.to_sym,
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Closes self as a duplicate of +target+ via GitHub's native +closeIssue+ GraphQL mutation with
|
|
505
|
+
# +stateReason: DUPLICATE+ and +duplicateIssueId+. The REST +duplicate_of+ body param is not recognized; only
|
|
506
|
+
# this GraphQL path actually wires up +Issue#duplicateOf+ on the closed issue.
|
|
507
|
+
#
|
|
508
|
+
# @raise [PlanMyStuff::Error] when source or target issue has no node_id
|
|
509
|
+
#
|
|
510
|
+
# @return [void]
|
|
511
|
+
#
|
|
512
|
+
def close_as_duplicate!(target)
|
|
513
|
+
source_node_id = github_node_id
|
|
514
|
+
target_node_id = target.github_node_id
|
|
515
|
+
raise(PlanMyStuff::Error, "Issue ##{number} has no node_id") if source_node_id.blank?
|
|
516
|
+
raise(PlanMyStuff::Error, "Target issue ##{target.number} has no node_id") if target_node_id.blank?
|
|
517
|
+
|
|
518
|
+
PlanMyStuff.client.graphql(
|
|
519
|
+
PlanMyStuff::GraphQL::Queries::CLOSE_AS_DUPLICATE,
|
|
520
|
+
variables: { issueId: source_node_id, duplicateIssueId: target_node_id },
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module IssueExtractions
|
|
5
|
+
module Viewers
|
|
6
|
+
# Adds user IDs to this issue's visibility allowlist (non-support users whose ID is in the allowlist can see
|
|
7
|
+
# internal comments).
|
|
8
|
+
#
|
|
9
|
+
# Fires +plan_my_stuff.issue.viewers_added+.
|
|
10
|
+
#
|
|
11
|
+
# @param user_ids [Array<Integer>, Integer]
|
|
12
|
+
# @param user [Object, nil] actor for the notification event
|
|
13
|
+
#
|
|
14
|
+
# @return [Array<Integer>] the new allowlist
|
|
15
|
+
#
|
|
16
|
+
def add_viewers!(user_ids:, user: nil)
|
|
17
|
+
ids = Array.wrap(user_ids)
|
|
18
|
+
modify_allowlist! { |allowlist| allowlist | ids }
|
|
19
|
+
PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
|
|
20
|
+
metadata.visibility_allowlist
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Removes user IDs from this issue's visibility allowlist.
|
|
24
|
+
#
|
|
25
|
+
# Fires +plan_my_stuff.issue.viewers_removed+.
|
|
26
|
+
#
|
|
27
|
+
# @param user_ids [Array<Integer>, Integer]
|
|
28
|
+
# @param user [Object, nil] actor for the notification event
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<Integer>] the new allowlist
|
|
31
|
+
#
|
|
32
|
+
def remove_viewers!(user_ids:, user: nil)
|
|
33
|
+
ids = Array.wrap(user_ids)
|
|
34
|
+
modify_allowlist! { |allowlist| allowlist - ids }
|
|
35
|
+
PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
|
|
36
|
+
metadata.visibility_allowlist
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Delegates visibility check to metadata.
|
|
40
|
+
# Non-PMS issues are always visible.
|
|
41
|
+
#
|
|
42
|
+
# @param user [Object, Integer] user object or user_id
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
#
|
|
46
|
+
def visible_to?(user)
|
|
47
|
+
if pms_issue?
|
|
48
|
+
metadata.visible_to?(user)
|
|
49
|
+
else
|
|
50
|
+
PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Yields +self.metadata.visibility_allowlist+ for modification, persists the updated allowlist via the
|
|
57
|
+
# class-level +update!+, and reloads +self+ so subsequent reads see the fresh state.
|
|
58
|
+
#
|
|
59
|
+
# @yieldparam allowlist [Array<Integer>]
|
|
60
|
+
# @yieldreturn [Array<Integer>] the new allowlist
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
#
|
|
64
|
+
def modify_allowlist!
|
|
65
|
+
new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
|
|
66
|
+
self.class.update!(
|
|
67
|
+
number: number,
|
|
68
|
+
repo: repo,
|
|
69
|
+
metadata: { visibility_allowlist: new_allowlist },
|
|
70
|
+
)
|
|
71
|
+
reload
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|