plan_my_stuff 0.9.0 → 0.10.1

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