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