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.
@@ -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