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
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'issue_extractions/approvals'
|
|
4
|
+
require_relative 'issue_extractions/links'
|
|
5
|
+
require_relative 'issue_extractions/viewers'
|
|
6
|
+
require_relative 'issue_extractions/waiting'
|
|
7
|
+
|
|
3
8
|
module PlanMyStuff
|
|
4
9
|
# Wraps a GitHub issue with parsed PMS metadata and comments.
|
|
5
10
|
# Class methods provide the public API for CRUD operations.
|
|
@@ -9,6 +14,11 @@ module PlanMyStuff
|
|
|
9
14
|
# - `Issue.create!` / `Issue.find` / `Issue.list` return persisted instances
|
|
10
15
|
# - `issue.save!` / `issue.update!` / `issue.reload` for persistence
|
|
11
16
|
class Issue < PlanMyStuff::ApplicationRecord
|
|
17
|
+
include PlanMyStuff::IssueExtractions::Approvals
|
|
18
|
+
include PlanMyStuff::IssueExtractions::Links
|
|
19
|
+
include PlanMyStuff::IssueExtractions::Viewers
|
|
20
|
+
include PlanMyStuff::IssueExtractions::Waiting
|
|
21
|
+
|
|
12
22
|
# @return [Integer, nil] GitHub issue number
|
|
13
23
|
attribute :number, :integer
|
|
14
24
|
# @return [String, nil] full body as stored on GitHub
|
|
@@ -363,7 +373,11 @@ module PlanMyStuff
|
|
|
363
373
|
canonical =
|
|
364
374
|
case value
|
|
365
375
|
when String
|
|
366
|
-
|
|
376
|
+
begin
|
|
377
|
+
resolve_issue_type!(value.to_sym)
|
|
378
|
+
rescue ArgumentError
|
|
379
|
+
value
|
|
380
|
+
end
|
|
367
381
|
when Symbol
|
|
368
382
|
ISSUE_TYPE_NICKNAMES[value] || raise(
|
|
369
383
|
ArgumentError,
|
|
@@ -486,311 +500,6 @@ module PlanMyStuff
|
|
|
486
500
|
"#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}"
|
|
487
501
|
end
|
|
488
502
|
|
|
489
|
-
# @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved + rejected)
|
|
490
|
-
def approvers
|
|
491
|
-
metadata.approvals
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
# @return [Array<PlanMyStuff::Approval>] approvers who have not yet acted (pending only; rejections are NOT
|
|
495
|
-
# pending -- the approver has responded)
|
|
496
|
-
def pending_approvals
|
|
497
|
-
approvers.select(&:pending?)
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
# @return [Array<PlanMyStuff::Approval>] approvers who have rejected
|
|
501
|
-
def rejected_approvals
|
|
502
|
-
approvers.select(&:rejected?)
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
# @return [Boolean] true when at least one approver is required on this issue
|
|
506
|
-
def approvals_required?
|
|
507
|
-
approvers.present?
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
# @return [Boolean] true when approvers are required AND every approver has approved. A single rejection blocks
|
|
511
|
-
# this gate until the approver revokes.
|
|
512
|
-
def fully_approved?
|
|
513
|
-
approvals_required? && approvers.all?(&:approved?)
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
# Adds user IDs to this issue's visibility allowlist (non-support users whose ID is in the allowlist can see
|
|
517
|
-
# internal comments).
|
|
518
|
-
#
|
|
519
|
-
# Fires +plan_my_stuff.issue.viewers_added+.
|
|
520
|
-
#
|
|
521
|
-
# @param user_ids [Array<Integer>, Integer]
|
|
522
|
-
# @param user [Object, nil] actor for the notification event
|
|
523
|
-
#
|
|
524
|
-
# @return [Array<Integer>] the new allowlist
|
|
525
|
-
#
|
|
526
|
-
def add_viewers!(user_ids:, user: nil)
|
|
527
|
-
ids = Array.wrap(user_ids)
|
|
528
|
-
modify_allowlist! { |allowlist| allowlist | ids }
|
|
529
|
-
PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
|
|
530
|
-
metadata.visibility_allowlist
|
|
531
|
-
end
|
|
532
|
-
|
|
533
|
-
# Removes user IDs from this issue's visibility allowlist.
|
|
534
|
-
#
|
|
535
|
-
# Fires +plan_my_stuff.issue.viewers_removed+.
|
|
536
|
-
#
|
|
537
|
-
# @param user_ids [Array<Integer>, Integer]
|
|
538
|
-
# @param user [Object, nil] actor for the notification event
|
|
539
|
-
#
|
|
540
|
-
# @return [Array<Integer>] the new allowlist
|
|
541
|
-
#
|
|
542
|
-
def remove_viewers!(user_ids:, user: nil)
|
|
543
|
-
ids = Array.wrap(user_ids)
|
|
544
|
-
modify_allowlist! { |allowlist| allowlist - ids }
|
|
545
|
-
PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
|
|
546
|
-
metadata.visibility_allowlist
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
# Adds approvers to this issue's required-approvals list. Idempotent: users already present are no-ops. Only
|
|
550
|
-
# support users may call this.
|
|
551
|
-
#
|
|
552
|
-
# Fires +plan_my_stuff.issue.approval_requested+ when any user is newly added. Also fires
|
|
553
|
-
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :approver_added+) when the new approvers flip the issue
|
|
554
|
-
# out of a fully-approved state.
|
|
555
|
-
#
|
|
556
|
-
# @param user_ids [Array<Integer>, Integer]
|
|
557
|
-
# @param user [Object, nil] actor; must be a support user
|
|
558
|
-
#
|
|
559
|
-
# @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
|
|
560
|
-
#
|
|
561
|
-
def request_approvals!(user_ids:, user: nil)
|
|
562
|
-
guard_support!(user)
|
|
563
|
-
ids = Array.wrap(user_ids).map(&:to_i)
|
|
564
|
-
|
|
565
|
-
just_added, was_fully_approved = modify_approvals! do |current|
|
|
566
|
-
existing_ids = current.map(&:user_id)
|
|
567
|
-
new_ids = ids - existing_ids
|
|
568
|
-
added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
|
|
569
|
-
[current + added, added]
|
|
570
|
-
end
|
|
571
|
-
|
|
572
|
-
finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved)
|
|
573
|
-
just_added
|
|
574
|
-
end
|
|
575
|
-
|
|
576
|
-
# Removes approvers from this issue's required-approvals list. Only support users may call this. Removing a
|
|
577
|
-
# pending approver may flip the issue into +fully_approved?+ (fires +all_approved+). Removing an approved
|
|
578
|
-
# approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue
|
|
579
|
-
# no longer has +approvals_required?+).
|
|
580
|
-
#
|
|
581
|
-
# @param user_ids [Array<Integer>, Integer]
|
|
582
|
-
# @param user [Object, nil] actor; must be a support user
|
|
583
|
-
#
|
|
584
|
-
# @return [Array<PlanMyStuff::Approval>] removed approval records
|
|
585
|
-
#
|
|
586
|
-
def remove_approvers!(user_ids:, user: nil)
|
|
587
|
-
guard_support!(user)
|
|
588
|
-
ids = Array.wrap(user_ids).map(&:to_i)
|
|
589
|
-
|
|
590
|
-
just_removed, was_fully_approved = modify_approvals! do |current|
|
|
591
|
-
removed = current.select { |a| ids.include?(a.user_id) }
|
|
592
|
-
[current - removed, removed]
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user)
|
|
596
|
-
just_removed
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
# Flips the caller's approval to +approved+ from any other state (+pending+ or +rejected+). Only the approver
|
|
600
|
-
# themselves may call this. Fires +plan_my_stuff.issue.approval_granted+ and, when this flip completes the
|
|
601
|
-
# approval set, +plan_my_stuff.issue.all_approved+.
|
|
602
|
-
#
|
|
603
|
-
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
|
|
604
|
-
#
|
|
605
|
-
# @param user [Object, Integer] actor; must resolve to an approver
|
|
606
|
-
#
|
|
607
|
-
# @return [PlanMyStuff::Approval] the updated approval
|
|
608
|
-
#
|
|
609
|
-
def approve!(user:)
|
|
610
|
-
actor_id = resolve_actor_id!(user)
|
|
611
|
-
|
|
612
|
-
just_approved, was_fully_approved = modify_approvals! do |current|
|
|
613
|
-
approval = current.find { |a| a.user_id == actor_id }
|
|
614
|
-
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
615
|
-
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved?
|
|
616
|
-
|
|
617
|
-
approval.status = 'approved'
|
|
618
|
-
approval.approved_at = Time.current
|
|
619
|
-
approval.rejected_at = nil
|
|
620
|
-
[current, approval]
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved)
|
|
624
|
-
just_approved
|
|
625
|
-
end
|
|
626
|
-
|
|
627
|
-
# Flips the caller's approval to +rejected+ from any other state (+pending+ or +approved+). Only the approver
|
|
628
|
-
# themselves may call this. Fires +plan_my_stuff.issue.approval_rejected+ and, when this flip drops the issue
|
|
629
|
-
# out of +fully_approved?+ (i.e. the caller was the last +approved+ approver),
|
|
630
|
-
# +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :rejected+).
|
|
631
|
-
#
|
|
632
|
-
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already rejected
|
|
633
|
-
#
|
|
634
|
-
# @param user [Object, Integer] actor; must resolve to an approver
|
|
635
|
-
#
|
|
636
|
-
# @return [PlanMyStuff::Approval] the updated approval
|
|
637
|
-
#
|
|
638
|
-
def reject!(user:)
|
|
639
|
-
actor_id = resolve_actor_id!(user)
|
|
640
|
-
|
|
641
|
-
just_rejected, was_fully_approved = modify_approvals! do |current|
|
|
642
|
-
approval = current.find { |a| a.user_id == actor_id }
|
|
643
|
-
raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
|
|
644
|
-
raise(PlanMyStuff::ValidationError, "User #{actor_id} has already rejected") if approval.rejected?
|
|
645
|
-
|
|
646
|
-
approval.status = 'rejected'
|
|
647
|
-
approval.rejected_at = Time.current
|
|
648
|
-
approval.approved_at = nil
|
|
649
|
-
[current, approval]
|
|
650
|
-
end
|
|
651
|
-
|
|
652
|
-
finish_state_change(
|
|
653
|
-
:approval_rejected,
|
|
654
|
-
just_rejected,
|
|
655
|
-
user: user,
|
|
656
|
-
was_fully_approved: was_fully_approved,
|
|
657
|
-
trigger: :rejected,
|
|
658
|
-
)
|
|
659
|
-
just_rejected
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
# Flips an approved or rejected record back to +pending+. Approvers may revoke their own response; support users
|
|
663
|
-
# may revoke any approver's response by passing +target_user_id:+. Non-support callers passing a
|
|
664
|
-
# +target_user_id:+ that is not their own raise +AuthorizationError+.
|
|
665
|
-
#
|
|
666
|
-
# Emits the granular event keyed off the source state: +plan_my_stuff.issue.approval_revoked+ from approved, or
|
|
667
|
-
# +plan_my_stuff.issue.rejection_revoked+ from rejected. When revoking an approval drops the issue out of
|
|
668
|
-
# +fully_approved?+, also fires +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+). Revoking a
|
|
669
|
-
# rejection cannot change +fully_approved?+ (the issue was already gated), so no aggregate event fires.
|
|
670
|
-
#
|
|
671
|
-
# @raise [PlanMyStuff::AuthorizationError] when a non-support caller targets another user
|
|
672
|
-
# @raise [PlanMyStuff::ValidationError] when the target is not in the approvers list or is currently pending
|
|
673
|
-
#
|
|
674
|
-
# @param user [Object, Integer] the caller
|
|
675
|
-
# @param target_user_id [Integer, nil] approver whose response should be revoked; defaults to the caller
|
|
676
|
-
#
|
|
677
|
-
# @return [PlanMyStuff::Approval] the updated approval
|
|
678
|
-
#
|
|
679
|
-
def revoke_approval!(user:, target_user_id: nil)
|
|
680
|
-
actor_id = resolve_actor_id!(user)
|
|
681
|
-
caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
682
|
-
target_id = target_user_id&.to_i || actor_id
|
|
683
|
-
|
|
684
|
-
if !caller_is_support && target_id != actor_id
|
|
685
|
-
raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
revoked_from = nil
|
|
689
|
-
just_revoked, was_fully_approved = modify_approvals! do |current|
|
|
690
|
-
approval = current.find { |a| a.user_id == target_id }
|
|
691
|
-
raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
|
|
692
|
-
if approval.pending?
|
|
693
|
-
raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded — nothing to revoke")
|
|
694
|
-
end
|
|
695
|
-
|
|
696
|
-
revoked_from = approval.status
|
|
697
|
-
approval.status = 'pending'
|
|
698
|
-
approval.approved_at = nil
|
|
699
|
-
approval.rejected_at = nil
|
|
700
|
-
[current, approval]
|
|
701
|
-
end
|
|
702
|
-
|
|
703
|
-
event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
|
|
704
|
-
finish_state_change(
|
|
705
|
-
event,
|
|
706
|
-
just_revoked,
|
|
707
|
-
user: user,
|
|
708
|
-
was_fully_approved: was_fully_approved,
|
|
709
|
-
trigger: (event == :approval_revoked) ? :revoked : nil,
|
|
710
|
-
)
|
|
711
|
-
just_revoked
|
|
712
|
-
end
|
|
713
|
-
|
|
714
|
-
# Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
|
|
715
|
-
# +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
|
|
716
|
-
# +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
|
|
717
|
-
# +Issues::WaitingsController+ toggle.
|
|
718
|
-
#
|
|
719
|
-
# @param user [Object, nil] actor for the label notification event
|
|
720
|
-
#
|
|
721
|
-
# @return [self]
|
|
722
|
-
#
|
|
723
|
-
def enter_waiting_on_user!(user: nil)
|
|
724
|
-
now = Time.now.utc
|
|
725
|
-
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
726
|
-
|
|
727
|
-
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
728
|
-
PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
729
|
-
|
|
730
|
-
self.class.update!(
|
|
731
|
-
number: number,
|
|
732
|
-
repo: repo,
|
|
733
|
-
metadata: {
|
|
734
|
-
waiting_on_user_at: PlanMyStuff.format_time(now),
|
|
735
|
-
next_reminder_at: format_next_reminder_at(from: now),
|
|
736
|
-
},
|
|
737
|
-
)
|
|
738
|
-
reload
|
|
739
|
-
end
|
|
740
|
-
|
|
741
|
-
# Clears the waiting-on-user state: removes the label, clears +metadata.waiting_on_user_at+, and clears
|
|
742
|
-
# +metadata.next_reminder_at+ unless a waiting-on-approval timer is still active. No-ops if the issue is not
|
|
743
|
-
# currently waiting on a user reply.
|
|
744
|
-
#
|
|
745
|
-
# @return [self]
|
|
746
|
-
#
|
|
747
|
-
def clear_waiting_on_user!
|
|
748
|
-
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
749
|
-
return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
|
|
750
|
-
|
|
751
|
-
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
752
|
-
|
|
753
|
-
self.class.update!(
|
|
754
|
-
number: number,
|
|
755
|
-
repo: repo,
|
|
756
|
-
metadata: {
|
|
757
|
-
waiting_on_user_at: nil,
|
|
758
|
-
next_reminder_at: metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
759
|
-
},
|
|
760
|
-
)
|
|
761
|
-
reload
|
|
762
|
-
end
|
|
763
|
-
|
|
764
|
-
# Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
|
|
765
|
-
# emits +plan_my_stuff.issue.reopened_by_reply+ carrying the reopening comment. Does not emit the regular
|
|
766
|
-
# +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
|
|
767
|
-
#
|
|
768
|
-
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
769
|
-
# @param user [Object, nil] actor for the notification event
|
|
770
|
-
#
|
|
771
|
-
# @return [self]
|
|
772
|
-
#
|
|
773
|
-
def reopen_by_reply!(comment:, user: nil)
|
|
774
|
-
inactive_label = PlanMyStuff.configuration.user_inactive_label
|
|
775
|
-
PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
776
|
-
|
|
777
|
-
self.class.update!(
|
|
778
|
-
number: number,
|
|
779
|
-
repo: repo,
|
|
780
|
-
state: :open,
|
|
781
|
-
metadata: { closed_by_inactivity: false },
|
|
782
|
-
)
|
|
783
|
-
reload
|
|
784
|
-
|
|
785
|
-
PlanMyStuff::Notifications.instrument(
|
|
786
|
-
'issue.reopened_by_reply',
|
|
787
|
-
self,
|
|
788
|
-
user: user,
|
|
789
|
-
comment: comment,
|
|
790
|
-
)
|
|
791
|
-
self
|
|
792
|
-
end
|
|
793
|
-
|
|
794
503
|
# Tags the issue with the configured +archived_label+, removes it from every Projects V2 board it belongs to,
|
|
795
504
|
# locks its conversation on GitHub, and stamps +metadata.archived_at+. Emits +plan_my_stuff.issue.archived+ on
|
|
796
505
|
# success.
|
|
@@ -942,255 +651,6 @@ module PlanMyStuff
|
|
|
942
651
|
super
|
|
943
652
|
end
|
|
944
653
|
|
|
945
|
-
# Delegates visibility check to metadata.
|
|
946
|
-
# Non-PMS issues are always visible.
|
|
947
|
-
#
|
|
948
|
-
# @param user [Object, Integer] user object or user_id
|
|
949
|
-
#
|
|
950
|
-
# @return [Boolean]
|
|
951
|
-
#
|
|
952
|
-
def visible_to?(user)
|
|
953
|
-
if pms_issue?
|
|
954
|
-
metadata.visible_to?(user)
|
|
955
|
-
else
|
|
956
|
-
PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
|
|
957
|
-
end
|
|
958
|
-
end
|
|
959
|
-
|
|
960
|
-
# Lazy-memoized array of +Issue+ objects for +:related+ links. Silently drops targets that 404 so a dangling
|
|
961
|
-
# pointer doesn't break the rest of the list.
|
|
962
|
-
#
|
|
963
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
964
|
-
#
|
|
965
|
-
def related
|
|
966
|
-
links_cache[:related] ||= fetch_related
|
|
967
|
-
end
|
|
968
|
-
|
|
969
|
-
# Adds a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the link back on
|
|
970
|
-
# +target+ so the pairing is symmetric. Dedups on +(type, issue_number, repo)+ - re-adding is a no-op.
|
|
971
|
-
#
|
|
972
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
973
|
-
# @param user [Object, nil] actor for notification events
|
|
974
|
-
# @param reciprocal [Boolean] internal flag; set by the mirror call
|
|
975
|
-
#
|
|
976
|
-
# @return [PlanMyStuff::Link]
|
|
977
|
-
#
|
|
978
|
-
def add_related!(target, user: nil, reciprocal: false)
|
|
979
|
-
link = build_link!(target, type: :related)
|
|
980
|
-
validate_not_self!(link)
|
|
981
|
-
|
|
982
|
-
existing = current_links
|
|
983
|
-
return link if existing.include?(link)
|
|
984
|
-
|
|
985
|
-
persist_links!(existing + [link])
|
|
986
|
-
unless reciprocal
|
|
987
|
-
mirror_on_target(link, user: user) { |other| other.add_related!(self, user: user, reciprocal: true) }
|
|
988
|
-
end
|
|
989
|
-
|
|
990
|
-
link
|
|
991
|
-
end
|
|
992
|
-
|
|
993
|
-
# Removes a +:related+ link to +target+ and, unless this call is already a reciprocal, mirrors the removal on
|
|
994
|
-
# +target+. No-op when the link isn't present locally.
|
|
995
|
-
#
|
|
996
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
997
|
-
# @param user [Object, nil]
|
|
998
|
-
# @param reciprocal [Boolean]
|
|
999
|
-
#
|
|
1000
|
-
# @return [PlanMyStuff::Link]
|
|
1001
|
-
#
|
|
1002
|
-
def remove_related!(target, user: nil, reciprocal: false)
|
|
1003
|
-
link = build_link!(target, type: :related)
|
|
1004
|
-
validate_not_self!(link)
|
|
1005
|
-
|
|
1006
|
-
existing = current_links
|
|
1007
|
-
return link if existing.exclude?(link)
|
|
1008
|
-
|
|
1009
|
-
persist_links!(existing.reject { |l| l == link })
|
|
1010
|
-
unless reciprocal
|
|
1011
|
-
mirror_on_target(link, user: user) { |other| other.remove_related!(self, user: user, reciprocal: true) }
|
|
1012
|
-
end
|
|
1013
|
-
|
|
1014
|
-
link
|
|
1015
|
-
end
|
|
1016
|
-
|
|
1017
|
-
# Lazy-memoized parent issue via GitHub's native sub-issues API. GitHub enforces at most one parent per issue.
|
|
1018
|
-
#
|
|
1019
|
-
# @return [PlanMyStuff::Issue, nil]
|
|
1020
|
-
#
|
|
1021
|
-
def parent
|
|
1022
|
-
return links_cache[:parent] if links_cache.key?(:parent)
|
|
1023
|
-
|
|
1024
|
-
links_cache[:parent] = fetch_parent
|
|
1025
|
-
end
|
|
1026
|
-
|
|
1027
|
-
# Lazy-memoized sub-issues via GitHub's native sub-issues API.
|
|
1028
|
-
#
|
|
1029
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
1030
|
-
#
|
|
1031
|
-
def sub_tickets
|
|
1032
|
-
links_cache[:sub_tickets] ||= fetch_sub_tickets
|
|
1033
|
-
end
|
|
1034
|
-
|
|
1035
|
-
# Adds +target+ as a sub-issue of self via +POST /issues/{number}/sub_issues+. Native GitHub action;
|
|
1036
|
-
# notifications are handled by GitHub itself.
|
|
1037
|
-
#
|
|
1038
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1039
|
-
#
|
|
1040
|
-
# @return [PlanMyStuff::Link]
|
|
1041
|
-
#
|
|
1042
|
-
def add_sub_issue!(target)
|
|
1043
|
-
mutate_sub_issue!(target, method: :post, path: sub_issues_path)
|
|
1044
|
-
end
|
|
1045
|
-
|
|
1046
|
-
# Removes +target+ as a sub-issue of self via +DELETE /issues/{number}/sub_issue+ (singular).
|
|
1047
|
-
#
|
|
1048
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1049
|
-
#
|
|
1050
|
-
# @return [PlanMyStuff::Link]
|
|
1051
|
-
#
|
|
1052
|
-
def remove_sub_issue!(target)
|
|
1053
|
-
mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
|
|
1054
|
-
end
|
|
1055
|
-
|
|
1056
|
-
# Makes +target+ the parent of self. If self already has a parent, it is detached first. Returns a +Link+
|
|
1057
|
-
# describing the new +:parent+ relationship.
|
|
1058
|
-
#
|
|
1059
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1060
|
-
#
|
|
1061
|
-
# @return [PlanMyStuff::Link]
|
|
1062
|
-
#
|
|
1063
|
-
def set_parent!(target)
|
|
1064
|
-
parent.presence&.remove_sub_issue!(self)
|
|
1065
|
-
|
|
1066
|
-
target_issue = resolve_target_issue(target, type: :parent)
|
|
1067
|
-
target_issue.add_sub_issue!(self)
|
|
1068
|
-
invalidate_links_cache!
|
|
1069
|
-
|
|
1070
|
-
build_link!(target_issue, type: :parent)
|
|
1071
|
-
end
|
|
1072
|
-
|
|
1073
|
-
# Detaches self from its current parent, if any. Returns the +Link+ that was removed, or nil when there was no
|
|
1074
|
-
# parent.
|
|
1075
|
-
#
|
|
1076
|
-
# @return [PlanMyStuff::Link, nil]
|
|
1077
|
-
#
|
|
1078
|
-
def remove_parent!
|
|
1079
|
-
current = parent
|
|
1080
|
-
return if current.nil?
|
|
1081
|
-
|
|
1082
|
-
current.remove_sub_issue!(self)
|
|
1083
|
-
invalidate_links_cache!
|
|
1084
|
-
|
|
1085
|
-
build_link!(current, type: :parent)
|
|
1086
|
-
end
|
|
1087
|
-
|
|
1088
|
-
# Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub's native
|
|
1089
|
-
# issue-dependency REST API.
|
|
1090
|
-
#
|
|
1091
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
1092
|
-
#
|
|
1093
|
-
def blocked_by
|
|
1094
|
-
links_cache[:blocked_by] ||= fetch_dependencies('blocked_by')
|
|
1095
|
-
end
|
|
1096
|
-
|
|
1097
|
-
# Lazy-memoized issues that self blocks.
|
|
1098
|
-
#
|
|
1099
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
1100
|
-
#
|
|
1101
|
-
def blocking
|
|
1102
|
-
links_cache[:blocking] ||= fetch_dependencies('blocking')
|
|
1103
|
-
end
|
|
1104
|
-
|
|
1105
|
-
# Records that +target+ blocks self. Native GitHub action; notifications are handled by GitHub itself.
|
|
1106
|
-
#
|
|
1107
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1108
|
-
#
|
|
1109
|
-
# @return [PlanMyStuff::Link]
|
|
1110
|
-
#
|
|
1111
|
-
def add_blocker!(target)
|
|
1112
|
-
link = build_link!(target, type: :blocked_by)
|
|
1113
|
-
validate_not_self!(link)
|
|
1114
|
-
|
|
1115
|
-
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
1116
|
-
PlanMyStuff.client.rest(
|
|
1117
|
-
:post,
|
|
1118
|
-
dependency_path('blocked_by'),
|
|
1119
|
-
{ issue_id: target_issue.__send__(:require_github_id!) },
|
|
1120
|
-
)
|
|
1121
|
-
invalidate_links_cache!
|
|
1122
|
-
link
|
|
1123
|
-
end
|
|
1124
|
-
|
|
1125
|
-
# Removes the record that +target+ blocks self.
|
|
1126
|
-
#
|
|
1127
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1128
|
-
#
|
|
1129
|
-
# @return [PlanMyStuff::Link]
|
|
1130
|
-
#
|
|
1131
|
-
def remove_blocker!(target)
|
|
1132
|
-
link = build_link!(target, type: :blocked_by)
|
|
1133
|
-
validate_not_self!(link)
|
|
1134
|
-
|
|
1135
|
-
target_issue = resolve_target_issue(target, type: :blocked_by)
|
|
1136
|
-
PlanMyStuff.client.rest(
|
|
1137
|
-
:delete,
|
|
1138
|
-
"#{dependency_path('blocked_by')}/#{target_issue.__send__(:require_github_id!)}",
|
|
1139
|
-
)
|
|
1140
|
-
invalidate_links_cache!
|
|
1141
|
-
link
|
|
1142
|
-
end
|
|
1143
|
-
|
|
1144
|
-
# Lazy-memoized issue that self was marked as duplicate of, via GitHub's native close-as-duplicate. Returns nil
|
|
1145
|
-
# for issues that are open or closed for other reasons.
|
|
1146
|
-
#
|
|
1147
|
-
# @return [PlanMyStuff::Issue, nil]
|
|
1148
|
-
#
|
|
1149
|
-
def duplicate_of
|
|
1150
|
-
return links_cache[:duplicate_of] if links_cache.key?(:duplicate_of)
|
|
1151
|
-
|
|
1152
|
-
links_cache[:duplicate_of] = fetch_duplicate_of
|
|
1153
|
-
end
|
|
1154
|
-
|
|
1155
|
-
# Closes self as a duplicate of +target+ via GitHub's native close-as-duplicate, carrying over viewers,
|
|
1156
|
-
# assignees, and a back-pointer comment on the target.
|
|
1157
|
-
#
|
|
1158
|
-
# Side effects, in order:
|
|
1159
|
-
# 1. Resolves +target+; raises +ValidationError+ if missing.
|
|
1160
|
-
# 2. Raises +ValidationError+ when self is already closed.
|
|
1161
|
-
# 3. Merges self's +visibility_allowlist+ onto target.
|
|
1162
|
-
# 4. Merges self's assignees onto target.
|
|
1163
|
-
# 5. Posts a PMS comment on target with the back-pointer.
|
|
1164
|
-
# 6. Closes self with +state_reason: :duplicate+ and
|
|
1165
|
-
# +duplicate_of: { owner:, repo:, number: }+.
|
|
1166
|
-
# 7. Reloads self; invalidates link caches.
|
|
1167
|
-
# 8. Fires +plan_my_stuff.issue.marked_duplicate+.
|
|
1168
|
-
#
|
|
1169
|
-
# Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.
|
|
1170
|
-
#
|
|
1171
|
-
# @raise [PlanMyStuff::ValidationError] when the issue is already closed
|
|
1172
|
-
#
|
|
1173
|
-
# @param target [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
1174
|
-
# @param user [Object, nil] actor for notification + comment
|
|
1175
|
-
#
|
|
1176
|
-
# @return [PlanMyStuff::Link]
|
|
1177
|
-
#
|
|
1178
|
-
def mark_duplicate!(target, user: nil)
|
|
1179
|
-
raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'
|
|
1180
|
-
|
|
1181
|
-
target_issue = resolve_duplicate_target!(target)
|
|
1182
|
-
merge_visibility_allowlist_onto!(target_issue)
|
|
1183
|
-
merge_assignees_onto!(target_issue)
|
|
1184
|
-
post_duplicate_back_pointer!(target_issue, user: user)
|
|
1185
|
-
close_as_duplicate!(target_issue)
|
|
1186
|
-
|
|
1187
|
-
reload
|
|
1188
|
-
invalidate_links_cache!
|
|
1189
|
-
PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)
|
|
1190
|
-
|
|
1191
|
-
build_link!(target_issue, type: :duplicate_of)
|
|
1192
|
-
end
|
|
1193
|
-
|
|
1194
654
|
# GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.
|
|
1195
655
|
#
|
|
1196
656
|
# @return [String, nil]
|
|
@@ -1210,193 +670,6 @@ module PlanMyStuff
|
|
|
1210
670
|
|
|
1211
671
|
private
|
|
1212
672
|
|
|
1213
|
-
# Yields +self.metadata.visibility_allowlist+ for modification, persists the updated allowlist via the
|
|
1214
|
-
# class-level +update!+, and reloads +self+ so subsequent reads see the fresh state.
|
|
1215
|
-
#
|
|
1216
|
-
# @yieldparam allowlist [Array<Integer>]
|
|
1217
|
-
# @yieldreturn [Array<Integer>] the new allowlist
|
|
1218
|
-
#
|
|
1219
|
-
# @return [void]
|
|
1220
|
-
#
|
|
1221
|
-
def modify_allowlist!
|
|
1222
|
-
new_allowlist = yield(Array.wrap(metadata.visibility_allowlist))
|
|
1223
|
-
self.class.update!(
|
|
1224
|
-
number: number,
|
|
1225
|
-
repo: repo,
|
|
1226
|
-
metadata: { visibility_allowlist: new_allowlist },
|
|
1227
|
-
)
|
|
1228
|
-
reload
|
|
1229
|
-
end
|
|
1230
|
-
|
|
1231
|
-
# Captures +fully_approved?+ state, yields the current approvals (deep-copied) for mutation, persists the new
|
|
1232
|
-
# list to GitHub, and reloads +self+. Returns +[extra, was_fully_approved]+.
|
|
1233
|
-
#
|
|
1234
|
-
# @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
|
|
1235
|
-
# @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
|
|
1236
|
-
#
|
|
1237
|
-
# @return [Array(Object, Boolean)]
|
|
1238
|
-
#
|
|
1239
|
-
def modify_approvals!
|
|
1240
|
-
was_fully_approved = fully_approved?
|
|
1241
|
-
was_pending_count = metadata.approvals.count(&:pending?)
|
|
1242
|
-
current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
|
|
1243
|
-
|
|
1244
|
-
new_list, extra = yield(current)
|
|
1245
|
-
|
|
1246
|
-
new_pending_count = new_list.count(&:pending?)
|
|
1247
|
-
metadata_updates = { approvals: new_list.map(&:to_h) }
|
|
1248
|
-
metadata_updates.merge!(waiting_on_approval_metadata_updates(was_pending_count, new_pending_count))
|
|
1249
|
-
|
|
1250
|
-
self.class.update!(number: number, repo: repo, metadata: metadata_updates)
|
|
1251
|
-
reload
|
|
1252
|
-
|
|
1253
|
-
sync_waiting_on_approval_label!(was_pending_count, new_pending_count)
|
|
1254
|
-
|
|
1255
|
-
[extra, was_fully_approved]
|
|
1256
|
-
end
|
|
1257
|
-
|
|
1258
|
-
# Computes the metadata delta for the waiting-on-approval timer based on the change in pending-approval count.
|
|
1259
|
-
# The timer resets only when pending count goes UP (add approver, revoke-to-pending) so that remaining pending
|
|
1260
|
-
# approvers keep their original schedule when a peer approves. Drop-to-zero clears the timer entirely.
|
|
1261
|
-
#
|
|
1262
|
-
# @param was [Integer] pending count before the mutation
|
|
1263
|
-
# @param now [Integer] pending count after the mutation
|
|
1264
|
-
#
|
|
1265
|
-
# @return [Hash]
|
|
1266
|
-
#
|
|
1267
|
-
def waiting_on_approval_metadata_updates(was, now)
|
|
1268
|
-
if now > was
|
|
1269
|
-
ts = Time.now.utc
|
|
1270
|
-
{
|
|
1271
|
-
waiting_on_approval_at: PlanMyStuff.format_time(ts),
|
|
1272
|
-
next_reminder_at: format_next_reminder_at(from: ts),
|
|
1273
|
-
}
|
|
1274
|
-
elsif now.zero? && was.positive?
|
|
1275
|
-
{
|
|
1276
|
-
waiting_on_approval_at: nil,
|
|
1277
|
-
next_reminder_at: metadata.waiting_on_user_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
1278
|
-
}
|
|
1279
|
-
else
|
|
1280
|
-
{}
|
|
1281
|
-
end
|
|
1282
|
-
end
|
|
1283
|
-
|
|
1284
|
-
# Adds or removes the configured waiting-on-approval label when the pending-approval count crosses the zero
|
|
1285
|
-
# boundary. Mutations that stay on the same side of zero leave the label untouched.
|
|
1286
|
-
#
|
|
1287
|
-
# @param was [Integer] pending count before the mutation
|
|
1288
|
-
# @param now [Integer] pending count after the mutation
|
|
1289
|
-
#
|
|
1290
|
-
# @return [void]
|
|
1291
|
-
#
|
|
1292
|
-
def sync_waiting_on_approval_label!(was, now)
|
|
1293
|
-
label = PlanMyStuff.configuration.waiting_on_approval_label
|
|
1294
|
-
|
|
1295
|
-
if now.positive? && was.zero?
|
|
1296
|
-
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
1297
|
-
PlanMyStuff::Label.add!(issue: self, labels: [label]) if labels.exclude?(label)
|
|
1298
|
-
elsif now.zero? && was.positive?
|
|
1299
|
-
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
1300
|
-
end
|
|
1301
|
-
end
|
|
1302
|
-
|
|
1303
|
-
# Ensures +user+ resolves to a support user. +nil+ user is treated as unauthorized.
|
|
1304
|
-
#
|
|
1305
|
-
# @raise [PlanMyStuff::AuthorizationError] when the actor is not a support user
|
|
1306
|
-
#
|
|
1307
|
-
# @param user [Object, Integer, nil]
|
|
1308
|
-
#
|
|
1309
|
-
# @return [void]
|
|
1310
|
-
#
|
|
1311
|
-
def guard_support!(user)
|
|
1312
|
-
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
1313
|
-
return if resolved && PlanMyStuff::UserResolver.support?(resolved)
|
|
1314
|
-
|
|
1315
|
-
raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
|
|
1316
|
-
end
|
|
1317
|
-
|
|
1318
|
-
# Resolves +user+ to an integer user_id.
|
|
1319
|
-
#
|
|
1320
|
-
# @raise [ArgumentError] when user is nil
|
|
1321
|
-
#
|
|
1322
|
-
# @param user [Object, Integer]
|
|
1323
|
-
#
|
|
1324
|
-
# @return [Integer]
|
|
1325
|
-
#
|
|
1326
|
-
def resolve_actor_id!(user)
|
|
1327
|
-
raise(ArgumentError, 'user: is required') if user.nil?
|
|
1328
|
-
|
|
1329
|
-
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
1330
|
-
PlanMyStuff::UserResolver.user_id(resolved)
|
|
1331
|
-
end
|
|
1332
|
-
|
|
1333
|
-
# Fires +approval_requested+ (when any users were newly added) and, if the aggregate state flipped out of
|
|
1334
|
-
# fully-approved, the +approvals_invalidated+ follow-up.
|
|
1335
|
-
#
|
|
1336
|
-
# @param added [Array<PlanMyStuff::Approval>]
|
|
1337
|
-
# @param user [Object, nil]
|
|
1338
|
-
# @param was_fully_approved [Boolean]
|
|
1339
|
-
#
|
|
1340
|
-
# @return [void]
|
|
1341
|
-
#
|
|
1342
|
-
def finish_request_approvals(added, user:, was_fully_approved:)
|
|
1343
|
-
return if added.empty?
|
|
1344
|
-
|
|
1345
|
-
PlanMyStuff::Notifications.instrument(
|
|
1346
|
-
'issue.approval_requested',
|
|
1347
|
-
self,
|
|
1348
|
-
user: user,
|
|
1349
|
-
approvals: added,
|
|
1350
|
-
)
|
|
1351
|
-
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
|
|
1352
|
-
end
|
|
1353
|
-
|
|
1354
|
-
# Fires the granular event (+approval_granted+ / +approval_revoked+) then any aggregate follow-up triggered
|
|
1355
|
-
# by the state flip.
|
|
1356
|
-
#
|
|
1357
|
-
# @param event [Symbol] +:approval_granted+ or +:approval_revoked+
|
|
1358
|
-
# @param approval [PlanMyStuff::Approval]
|
|
1359
|
-
# @param user [Object, nil]
|
|
1360
|
-
# @param was_fully_approved [Boolean]
|
|
1361
|
-
# @param trigger [Symbol, nil] passed through to +approvals_invalidated+
|
|
1362
|
-
#
|
|
1363
|
-
# @return [void]
|
|
1364
|
-
#
|
|
1365
|
-
def finish_state_change(event, approval, user:, was_fully_approved:, trigger: nil)
|
|
1366
|
-
PlanMyStuff::Notifications.instrument(
|
|
1367
|
-
"issue.#{event}",
|
|
1368
|
-
self,
|
|
1369
|
-
user: user,
|
|
1370
|
-
approval: approval,
|
|
1371
|
-
)
|
|
1372
|
-
emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
|
|
1373
|
-
end
|
|
1374
|
-
|
|
1375
|
-
# Fires +all_approved+ or +approvals_invalidated+ based on whether +fully_approved?+ flipped. Suppresses
|
|
1376
|
-
# +approvals_invalidated+ when the issue no longer has any approvers required (dropping the list to empty is
|
|
1377
|
-
# not an invalidation).
|
|
1378
|
-
#
|
|
1379
|
-
# @param was_fully_approved [Boolean]
|
|
1380
|
-
# @param trigger [Symbol, nil]
|
|
1381
|
-
# @param user [Object, nil]
|
|
1382
|
-
#
|
|
1383
|
-
# @return [void]
|
|
1384
|
-
#
|
|
1385
|
-
def emit_aggregate_events(was_fully_approved:, trigger:, user:)
|
|
1386
|
-
now = fully_approved?
|
|
1387
|
-
|
|
1388
|
-
if !was_fully_approved && now
|
|
1389
|
-
PlanMyStuff::Notifications.instrument('issue.all_approved', self, user: user)
|
|
1390
|
-
elsif was_fully_approved && !now && approvals_required?
|
|
1391
|
-
PlanMyStuff::Notifications.instrument(
|
|
1392
|
-
'issue.approvals_invalidated',
|
|
1393
|
-
self,
|
|
1394
|
-
user: user,
|
|
1395
|
-
trigger: trigger,
|
|
1396
|
-
)
|
|
1397
|
-
end
|
|
1398
|
-
end
|
|
1399
|
-
|
|
1400
673
|
# Populates this instance from a GitHub API response.
|
|
1401
674
|
#
|
|
1402
675
|
# @param github_issue [Object] Octokit issue response
|
|
@@ -1454,20 +727,6 @@ module PlanMyStuff
|
|
|
1454
727
|
invalidate_links_cache!
|
|
1455
728
|
end
|
|
1456
729
|
|
|
1457
|
-
# Formats the next reminder time as an ISO 8601 UTC string, using per-issue +metadata.reminder_days+ when set
|
|
1458
|
-
# or +config.reminder_days+ otherwise. Returns +nil+ when the effective schedule is empty.
|
|
1459
|
-
#
|
|
1460
|
-
# @param from [Time] baseline timestamp
|
|
1461
|
-
#
|
|
1462
|
-
# @return [String, nil]
|
|
1463
|
-
#
|
|
1464
|
-
def format_next_reminder_at(from:)
|
|
1465
|
-
days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
1466
|
-
return if days.empty?
|
|
1467
|
-
|
|
1468
|
-
PlanMyStuff.format_time(from + days.first.days)
|
|
1469
|
-
end
|
|
1470
|
-
|
|
1471
730
|
# Fires the appropriate notification event for an update: +issue.closed+ or +issue.reopened+ on a state
|
|
1472
731
|
# transition, otherwise +issue.updated+ with the captured dirty-tracking diff.
|
|
1473
732
|
#
|
|
@@ -1487,50 +746,6 @@ module PlanMyStuff
|
|
|
1487
746
|
end
|
|
1488
747
|
end
|
|
1489
748
|
|
|
1490
|
-
# When an issue is transitioning from open to closed, strips both waiting labels from the outgoing labels
|
|
1491
|
-
# array and clears the waiting-related timestamps on +metadata+ so a single save writes both state change and
|
|
1492
|
-
# cleanup. No-op for any other transition.
|
|
1493
|
-
#
|
|
1494
|
-
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1495
|
-
#
|
|
1496
|
-
# @return [void]
|
|
1497
|
-
#
|
|
1498
|
-
def clear_waiting_state_on_close(attrs)
|
|
1499
|
-
return unless state_changed?
|
|
1500
|
-
return unless state_was == 'open'
|
|
1501
|
-
return unless state == 'closed'
|
|
1502
|
-
|
|
1503
|
-
return if metadata.waiting_on_user_at.blank? && metadata.waiting_on_approval_at.blank?
|
|
1504
|
-
|
|
1505
|
-
waiting_labels = [
|
|
1506
|
-
PlanMyStuff.configuration.waiting_on_user_label,
|
|
1507
|
-
PlanMyStuff.configuration.waiting_on_approval_label,
|
|
1508
|
-
]
|
|
1509
|
-
attrs[:labels] = Array.wrap(attrs[:labels]) - waiting_labels
|
|
1510
|
-
|
|
1511
|
-
metadata.waiting_on_user_at = nil
|
|
1512
|
-
metadata.waiting_on_approval_at = nil
|
|
1513
|
-
metadata.next_reminder_at = nil
|
|
1514
|
-
end
|
|
1515
|
-
|
|
1516
|
-
# When an inactivity-closed issue is being reopened, strips the +user_inactive_label+ from the outgoing labels
|
|
1517
|
-
# and clears +metadata.closed_by_inactivity+ so the save writes both. No-op for any other transition or for
|
|
1518
|
-
# reopens of non-inactive closes.
|
|
1519
|
-
#
|
|
1520
|
-
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
1521
|
-
#
|
|
1522
|
-
# @return [void]
|
|
1523
|
-
#
|
|
1524
|
-
def clear_inactivity_state_on_reopen(attrs)
|
|
1525
|
-
return unless state_changed?
|
|
1526
|
-
return unless state_was == 'closed'
|
|
1527
|
-
return unless state == 'open'
|
|
1528
|
-
return unless metadata.closed_by_inactivity
|
|
1529
|
-
|
|
1530
|
-
attrs[:labels] = Array.wrap(attrs[:labels]) - [PlanMyStuff.configuration.user_inactive_label]
|
|
1531
|
-
metadata.closed_by_inactivity = false
|
|
1532
|
-
end
|
|
1533
|
-
|
|
1534
749
|
# Full-write persistence path for an already-persisted issue. Delegates to +Issue.update!+ passing the full
|
|
1535
750
|
# in-memory state (title/state/labels plus the current +metadata+ object so the class method serializes it
|
|
1536
751
|
# authoritatively). Only passes +body:+ when +@body_dirty+, so the PMS body comment is rewritten exactly when
|
|
@@ -1649,69 +864,6 @@ module PlanMyStuff
|
|
|
1649
864
|
PlanMyStuff::Comment.list(issue: self)
|
|
1650
865
|
end
|
|
1651
866
|
|
|
1652
|
-
# @return [Hash{Symbol => Array}]
|
|
1653
|
-
def links_cache
|
|
1654
|
-
@links_cache ||= {}
|
|
1655
|
-
end
|
|
1656
|
-
|
|
1657
|
-
# Clears all memoized link readers. Called from +#hydrate_from_github+ and after any successful write.
|
|
1658
|
-
#
|
|
1659
|
-
# @return [void]
|
|
1660
|
-
#
|
|
1661
|
-
def invalidate_links_cache!
|
|
1662
|
-
@links_cache = {}
|
|
1663
|
-
end
|
|
1664
|
-
|
|
1665
|
-
# Normalizes +target+ to a +PlanMyStuff::Link+ with the source repo defaulting to self's repo.
|
|
1666
|
-
#
|
|
1667
|
-
# @return [PlanMyStuff::Link]
|
|
1668
|
-
#
|
|
1669
|
-
def build_link!(target, type:)
|
|
1670
|
-
PlanMyStuff::Link.build!(target, type: type, source_repo: repo&.full_name)
|
|
1671
|
-
end
|
|
1672
|
-
|
|
1673
|
-
# @raise [PlanMyStuff::ValidationError] when target is the same issue (self-link)
|
|
1674
|
-
#
|
|
1675
|
-
# @return [void]
|
|
1676
|
-
#
|
|
1677
|
-
def validate_not_self!(link)
|
|
1678
|
-
return if link.issue_number != number
|
|
1679
|
-
return unless link.same_repo?(repo)
|
|
1680
|
-
|
|
1681
|
-
raise(PlanMyStuff::ValidationError, 'Cannot link an issue to itself')
|
|
1682
|
-
end
|
|
1683
|
-
|
|
1684
|
-
# Reads +metadata.links+ and coerces any legacy hash entries to +Link+ instances. Invalid entries are dropped.
|
|
1685
|
-
#
|
|
1686
|
-
# @return [Array<PlanMyStuff::Link>]
|
|
1687
|
-
#
|
|
1688
|
-
def current_links
|
|
1689
|
-
metadata.links.filter_map do |entry|
|
|
1690
|
-
next entry if entry.is_a?(PlanMyStuff::Link)
|
|
1691
|
-
|
|
1692
|
-
PlanMyStuff::Link.build!(entry)
|
|
1693
|
-
rescue ActiveModel::ValidationError, ArgumentError
|
|
1694
|
-
next
|
|
1695
|
-
end
|
|
1696
|
-
end
|
|
1697
|
-
|
|
1698
|
-
# Writes the given link array back to GitHub via +Issue.update!+ and updates local metadata so subsequent
|
|
1699
|
-
# in-memory reads see the change without a +reload+.
|
|
1700
|
-
#
|
|
1701
|
-
# @param new_links [Array<PlanMyStuff::Link>]
|
|
1702
|
-
#
|
|
1703
|
-
# @return [void]
|
|
1704
|
-
#
|
|
1705
|
-
def persist_links!(new_links)
|
|
1706
|
-
self.class.update!(
|
|
1707
|
-
number: number,
|
|
1708
|
-
repo: repo,
|
|
1709
|
-
metadata: { links: new_links.map(&:to_h) },
|
|
1710
|
-
)
|
|
1711
|
-
metadata.links = new_links
|
|
1712
|
-
invalidate_links_cache!
|
|
1713
|
-
end
|
|
1714
|
-
|
|
1715
867
|
# Walks every Projects V2 board this issue sits on and deletes the corresponding item. Paginates via
|
|
1716
868
|
# +LIST_ISSUE_PROJECT_ITEMS+ with a safety cap to avoid runaway loops. Delete failures propagate.
|
|
1717
869
|
#
|
|
@@ -1746,135 +898,6 @@ module PlanMyStuff
|
|
|
1746
898
|
end
|
|
1747
899
|
end
|
|
1748
900
|
|
|
1749
|
-
# Attempts the reciprocal write on +link+'s target. On failure, fires
|
|
1750
|
-
# +plan_my_stuff.issue.link_reciprocal_failed+ so the consuming app can surface the half-written pairing.
|
|
1751
|
-
#
|
|
1752
|
-
# @param link [PlanMyStuff::Link]
|
|
1753
|
-
# @param user [Object, nil]
|
|
1754
|
-
#
|
|
1755
|
-
# @return [void]
|
|
1756
|
-
#
|
|
1757
|
-
def mirror_on_target(link, user:)
|
|
1758
|
-
target = PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1759
|
-
yield(target)
|
|
1760
|
-
rescue PlanMyStuff::Error, Octokit::Error => e
|
|
1761
|
-
PlanMyStuff::Notifications.instrument(
|
|
1762
|
-
'issue.link_reciprocal_failed',
|
|
1763
|
-
self,
|
|
1764
|
-
user: user,
|
|
1765
|
-
link: link,
|
|
1766
|
-
error: e.message,
|
|
1767
|
-
)
|
|
1768
|
-
end
|
|
1769
|
-
|
|
1770
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
1771
|
-
def fetch_related
|
|
1772
|
-
current_links.filter_map do |link|
|
|
1773
|
-
next unless link.type == 'related'
|
|
1774
|
-
|
|
1775
|
-
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1776
|
-
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
1777
|
-
next
|
|
1778
|
-
end
|
|
1779
|
-
end
|
|
1780
|
-
|
|
1781
|
-
# @return [PlanMyStuff::Issue, nil]
|
|
1782
|
-
def fetch_parent
|
|
1783
|
-
response = PlanMyStuff.client.rest(:get, parent_path)
|
|
1784
|
-
return if response.blank?
|
|
1785
|
-
|
|
1786
|
-
parent_number = response.respond_to?(:number) ? response.number : response[:number]
|
|
1787
|
-
PlanMyStuff::Issue.find(parent_number, repo: repo)
|
|
1788
|
-
rescue PlanMyStuff::APIError => e
|
|
1789
|
-
return if e.status == 404
|
|
1790
|
-
|
|
1791
|
-
raise
|
|
1792
|
-
end
|
|
1793
|
-
|
|
1794
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
1795
|
-
def fetch_sub_tickets
|
|
1796
|
-
response = PlanMyStuff.client.rest(:get, sub_issues_path)
|
|
1797
|
-
Array.wrap(response).filter_map do |row|
|
|
1798
|
-
sub_number = row.respond_to?(:number) ? row.number : row[:number]
|
|
1799
|
-
PlanMyStuff::Issue.find(sub_number, repo: repo)
|
|
1800
|
-
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
1801
|
-
next
|
|
1802
|
-
end
|
|
1803
|
-
end
|
|
1804
|
-
|
|
1805
|
-
# Normalizes +target+ to a fully-hydrated +Issue+ (fetching when we only have a +Link+ or hash). Used by
|
|
1806
|
-
# +set_parent!+ / +remove_parent!+ to invert the call back through +#add_sub_issue!+ / +#remove_sub_issue!+ on
|
|
1807
|
-
# the parent side.
|
|
1808
|
-
#
|
|
1809
|
-
# @return [PlanMyStuff::Issue]
|
|
1810
|
-
#
|
|
1811
|
-
def resolve_target_issue(target, type:)
|
|
1812
|
-
return target if target.is_a?(PlanMyStuff::Issue)
|
|
1813
|
-
|
|
1814
|
-
link = build_link!(target, type: type)
|
|
1815
|
-
PlanMyStuff::Issue.find(link.issue_number, repo: link.repo)
|
|
1816
|
-
end
|
|
1817
|
-
|
|
1818
|
-
# Shared path for add_sub_issue! / remove_sub_issue!. Builds the link, resolves the target, runs the
|
|
1819
|
-
# mutation, busts caches.
|
|
1820
|
-
#
|
|
1821
|
-
# @return [PlanMyStuff::Link]
|
|
1822
|
-
#
|
|
1823
|
-
def mutate_sub_issue!(target, method:, path:)
|
|
1824
|
-
link = build_link!(target, type: :sub_ticket)
|
|
1825
|
-
validate_not_self!(link)
|
|
1826
|
-
|
|
1827
|
-
target_issue = resolve_target_issue(target, type: :sub_ticket)
|
|
1828
|
-
PlanMyStuff.client.rest(
|
|
1829
|
-
method,
|
|
1830
|
-
path,
|
|
1831
|
-
{ sub_issue_id: target_issue.__send__(:require_github_id!) },
|
|
1832
|
-
)
|
|
1833
|
-
invalidate_links_cache!
|
|
1834
|
-
link
|
|
1835
|
-
end
|
|
1836
|
-
|
|
1837
|
-
# @return [String]
|
|
1838
|
-
def parent_path
|
|
1839
|
-
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/parent"
|
|
1840
|
-
end
|
|
1841
|
-
|
|
1842
|
-
# @return [String]
|
|
1843
|
-
def sub_issues_path
|
|
1844
|
-
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issues"
|
|
1845
|
-
end
|
|
1846
|
-
|
|
1847
|
-
# GitHub's REMOVE endpoint is +/sub_issue+ (singular), distinct from the list/add path +/sub_issues+ (plural).
|
|
1848
|
-
#
|
|
1849
|
-
# @return [String]
|
|
1850
|
-
#
|
|
1851
|
-
def remove_sub_issue_path
|
|
1852
|
-
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/sub_issue"
|
|
1853
|
-
end
|
|
1854
|
-
|
|
1855
|
-
# Fetches one side of the native issue-dependency graph for self (+blocked_by+ or +blocking+) via REST.
|
|
1856
|
-
# Response is an array of Issue objects; we map through +Issue.find+ to get fully hydrated instances (the
|
|
1857
|
-
# dependency endpoint returns a slim projection).
|
|
1858
|
-
#
|
|
1859
|
-
# @param side [String] "blocked_by" or "blocking"
|
|
1860
|
-
#
|
|
1861
|
-
# @return [Array<PlanMyStuff::Issue>]
|
|
1862
|
-
#
|
|
1863
|
-
def fetch_dependencies(side)
|
|
1864
|
-
response = PlanMyStuff.client.rest(:get, dependency_path(side))
|
|
1865
|
-
Array.wrap(response).filter_map do |row|
|
|
1866
|
-
number = row.respond_to?(:number) ? row.number : row[:number]
|
|
1867
|
-
PlanMyStuff::Issue.find(number, repo: repo)
|
|
1868
|
-
rescue PlanMyStuff::APIError, Octokit::NotFound
|
|
1869
|
-
next
|
|
1870
|
-
end
|
|
1871
|
-
end
|
|
1872
|
-
|
|
1873
|
-
# @return [String]
|
|
1874
|
-
def dependency_path(side)
|
|
1875
|
-
"/repos/#{repo.organization}/#{repo.name}/issues/#{number}/dependencies/#{side}"
|
|
1876
|
-
end
|
|
1877
|
-
|
|
1878
901
|
# @raise [PlanMyStuff::Error]
|
|
1879
902
|
# @return [Integer]
|
|
1880
903
|
#
|
|
@@ -1884,95 +907,5 @@ module PlanMyStuff
|
|
|
1884
907
|
|
|
1885
908
|
id
|
|
1886
909
|
end
|
|
1887
|
-
|
|
1888
|
-
# @return [PlanMyStuff::Issue, nil]
|
|
1889
|
-
def fetch_duplicate_of
|
|
1890
|
-
data = PlanMyStuff.client.graphql(
|
|
1891
|
-
PlanMyStuff::GraphQL::Queries::FETCH_DUPLICATE_OF,
|
|
1892
|
-
variables: { owner: repo.organization, repo: repo.name, number: number },
|
|
1893
|
-
)
|
|
1894
|
-
issue_data = data.dig(:repository, :issue) || {}
|
|
1895
|
-
return unless issue_data[:stateReason].to_s.casecmp?('DUPLICATE')
|
|
1896
|
-
|
|
1897
|
-
the_dupe = issue_data[:duplicateOf]
|
|
1898
|
-
return if the_dupe.blank?
|
|
1899
|
-
|
|
1900
|
-
PlanMyStuff::Issue.find(the_dupe[:number], repo: the_dupe.dig(:repository, :nameWithOwner))
|
|
1901
|
-
end
|
|
1902
|
-
|
|
1903
|
-
# Resolves +target+ to an +Issue+.
|
|
1904
|
-
#
|
|
1905
|
-
# @raise [PlanMyStuff::ValidationError] when the duplicate target cannot be found
|
|
1906
|
-
#
|
|
1907
|
-
# @return [PlanMyStuff::Issue]
|
|
1908
|
-
#
|
|
1909
|
-
def resolve_duplicate_target!(target)
|
|
1910
|
-
resolve_target_issue(target, type: :duplicate_of)
|
|
1911
|
-
rescue Octokit::NotFound, PlanMyStuff::APIError => e
|
|
1912
|
-
raise(PlanMyStuff::ValidationError, "Duplicate target not found: #{e.message}")
|
|
1913
|
-
end
|
|
1914
|
-
|
|
1915
|
-
# Unions self's visibility_allowlist onto +target+'s.
|
|
1916
|
-
#
|
|
1917
|
-
# @return [void]
|
|
1918
|
-
#
|
|
1919
|
-
def merge_visibility_allowlist_onto!(target)
|
|
1920
|
-
return if metadata.visibility_allowlist.blank?
|
|
1921
|
-
|
|
1922
|
-
merged = Array.wrap(target.metadata.visibility_allowlist) | Array.wrap(metadata.visibility_allowlist)
|
|
1923
|
-
target.update!(metadata: { visibility_allowlist: merged })
|
|
1924
|
-
end
|
|
1925
|
-
|
|
1926
|
-
# Unions self's GitHub assignees (by login) onto +target+'s.
|
|
1927
|
-
#
|
|
1928
|
-
# @return [void]
|
|
1929
|
-
#
|
|
1930
|
-
def merge_assignees_onto!(target)
|
|
1931
|
-
source_logins = extract_assignee_logins(github_response)
|
|
1932
|
-
return if source_logins.empty?
|
|
1933
|
-
|
|
1934
|
-
merged = extract_assignee_logins(target.github_response) | source_logins
|
|
1935
|
-
target.update!(assignees: merged)
|
|
1936
|
-
end
|
|
1937
|
-
|
|
1938
|
-
# @param response [Object] Octokit issue response
|
|
1939
|
-
#
|
|
1940
|
-
# @return [Array<String>]
|
|
1941
|
-
#
|
|
1942
|
-
def extract_assignee_logins(response)
|
|
1943
|
-
raw = safe_read_field(response, :assignees) || []
|
|
1944
|
-
raw.filter_map { |a| a.respond_to?(:login) ? a.login : a[:login] || a['login'] }
|
|
1945
|
-
end
|
|
1946
|
-
|
|
1947
|
-
# @return [void]
|
|
1948
|
-
def post_duplicate_back_pointer!(target, user:)
|
|
1949
|
-
visibility = target.metadata.visibility.presence || 'public'
|
|
1950
|
-
PlanMyStuff::Comment.create!(
|
|
1951
|
-
issue: target,
|
|
1952
|
-
body: "Marked duplicate of this by #{repo.full_name}##{number}",
|
|
1953
|
-
user: user,
|
|
1954
|
-
visibility: visibility.to_sym,
|
|
1955
|
-
)
|
|
1956
|
-
end
|
|
1957
|
-
|
|
1958
|
-
# Closes self as a duplicate of +target+ via GitHub's native +closeIssue+ GraphQL mutation with
|
|
1959
|
-
# +stateReason: DUPLICATE+ and +duplicateIssueId+. The REST +duplicate_of+ body param is not recognized; only
|
|
1960
|
-
# this GraphQL path actually wires up +Issue#duplicateOf+ on the closed issue.
|
|
1961
|
-
#
|
|
1962
|
-
# @raise [PlanMyStuff::Error] when source or target issue has no node_id
|
|
1963
|
-
#
|
|
1964
|
-
# @return [void]
|
|
1965
|
-
#
|
|
1966
|
-
def close_as_duplicate!(target)
|
|
1967
|
-
source_node_id = github_node_id
|
|
1968
|
-
target_node_id = target.github_node_id
|
|
1969
|
-
raise(PlanMyStuff::Error, "Issue ##{number} has no node_id") if source_node_id.blank?
|
|
1970
|
-
raise(PlanMyStuff::Error, "Target issue ##{target.number} has no node_id") if target_node_id.blank?
|
|
1971
|
-
|
|
1972
|
-
PlanMyStuff.client.graphql(
|
|
1973
|
-
PlanMyStuff::GraphQL::Queries::CLOSE_AS_DUPLICATE,
|
|
1974
|
-
variables: { issueId: source_node_id, duplicateIssueId: target_node_id },
|
|
1975
|
-
)
|
|
1976
|
-
end
|
|
1977
910
|
end
|
|
1978
911
|
end
|