Class: PlanMyStuff::Issue

Inherits:
ApplicationRecord show all
Defined in:
lib/plan_my_stuff/issue.rb

Overview

Wraps a GitHub issue with parsed PMS metadata and comments. Class methods provide the public API for CRUD operations.

Follows an ActiveRecord-style pattern:

  • ‘Issue.new(**attrs)` creates an unpersisted instance

  • ‘Issue.create!` / `Issue.find` / `Issue.list` return persisted instances

  • ‘issue.save!` / `issue.update!` / `issue.reload` for persistence

Instance Attribute Summary

Attributes inherited from ApplicationRecord

#github_response

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

#destroyed?, #new_record?, #persisted?, read_field

Constructor Details

#initialize(**attrs) ⇒ Issue

Returns a new instance of Issue.



277
278
279
280
# File 'lib/plan_my_stuff/issue.rb', line 277

def initialize(**attrs)
  @body_dirty = false
  super
end

Class Method Details

.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: []) ⇒ PlanMyStuff::Issue

Creates a GitHub issue with PMS metadata embedded in the body.

Parameters:

  • title (String)
  • body (String)
  • repo (Symbol, String, nil) (defaults to: nil)

    defaults to config.default_repo

  • labels (Array<String>) (defaults to: [])
  • user (Object, Integer) (defaults to: nil)

    user object or user_id

  • metadata (Hash) (defaults to: {})

    custom fields hash

  • add_to_project (Boolean, Integer, nil) (defaults to: nil)
  • visibility (String) (defaults to: 'public')

    “public” or “internal”

  • visibility_allowlist (Array<Integer>) (defaults to: [])

    user IDs for internal comment access

Returns:

Raises:



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/plan_my_stuff/issue.rb', line 52

def create!(
  title:,
  body:,
  repo: nil,
  labels: [],
  user: nil,
  metadata: {},
  add_to_project: nil,
  visibility: 'public',
  visibility_allowlist: []
)
  raise(ValidationError.new('body must be present', field: :body, expected_type: :string)) if body.blank?

  client = PlanMyStuff.client
  resolved_repo = client.resolve_repo(repo)

   = IssueMetadata.build(
    user: user,
    visibility: visibility,
    custom_fields: ,
  )
  .visibility_allowlist = Array.wrap(visibility_allowlist)
  .validate_custom_fields!

  serialized_body = MetadataParser.serialize(.to_h, '')

  options = {}
  options[:labels] = labels if labels.any?

  result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
  number = read_field(result, :number)
  store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)

  issue = find(number, repo: resolved_repo)

  if add_to_project.present?
    project_number = resolve_project_number(add_to_project)
    ProjectItem.create!(issue, project_number: project_number)
  end

  Comment.create!(
    issue: issue,
    body: body,
    user: user,
    visibility: .visibility.to_sym,
    skip_responded: true,
    issue_body: true,
  )

  issue.reload
  PlanMyStuff::Notifications.instrument('issue.created', issue, user: user)
  issue
end

.find(number, repo: nil) ⇒ PlanMyStuff::Issue

Finds a single GitHub issue by number and parses its PMS metadata.

Parameters:

  • number (Integer)
  • repo (Symbol, String, nil) (defaults to: nil)

    defaults to config.default_repo

Returns:



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/plan_my_stuff/issue.rb', line 184

def find(number, repo: nil)
  client = PlanMyStuff.client
  resolved_repo = client.resolve_repo(repo)

  github_issue =
    fetch_with_etag_cache(
      client,
      resolved_repo,
      number,
      rest_method: :issue,
      cache_reader: :read_issue,
      cache_writer: :write_issue,
    )

  if github_issue.respond_to?(:pull_request) && github_issue.pull_request
    raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
  end

  build(github_issue, repo: resolved_repo)
end

.list(repo: nil, state: :open, labels: [], page: 1, per_page: 25) ⇒ Array<PlanMyStuff::Issue>

Lists GitHub issues with optional filters and pagination.

Parameters:

  • repo (Symbol, String, nil) (defaults to: nil)

    defaults to config.default_repo

  • state (Symbol) (defaults to: :open)

    :open, :closed, or :all

  • labels (Array<String>) (defaults to: [])
  • page (Integer) (defaults to: 1)
  • per_page (Integer) (defaults to: 25)

Returns:



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/plan_my_stuff/issue.rb', line 215

def list(repo: nil, state: :open, labels: [], page: 1, per_page: 25)
  client = PlanMyStuff.client
  resolved_repo = client.resolve_repo(repo)

  params = { state: state.to_s, page: page, per_page: per_page }
  params[:labels] = labels.sort.join(',') if labels.any?

  cached = PlanMyStuff::Cache.read_list(:issue, resolved_repo, params)
  request_options = cached ? params.merge(headers: { 'If-None-Match' => cached[:etag] }) : params

  github_issues = client.rest(:list_issues, resolved_repo, **request_options)

  if cached && not_modified?(client)
    return cached[:body].map { |gi| build(gi, repo: resolved_repo) }
  end

  filtered = github_issues.reject { |gi| gi.respond_to?(:pull_request) && gi.pull_request }
  store_list_etag_to_cache(client, :issue, resolved_repo, params, filtered)
  filtered.map { |gi| build(gi, repo: resolved_repo) }
end

.update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil) ⇒ Object

Updates an existing GitHub issue.

metadata: accepts either:

  • a PlanMyStuff::IssueMetadata instance - treated as the full authoritative metadata and serialized as-is (used by instance save!/update! so local @metadata mutations like metadata.commit_sha = … actually persist).

  • a Hash - patch-style merge against the CURRENT remote metadata. Top-level keys are merged in; :custom_fields is merged separately so unrelated fields stay intact.

Parameters:

  • number (Integer)
  • repo (Symbol, String, nil) (defaults to: nil)

    defaults to config.default_repo

  • title (String, nil) (defaults to: nil)
  • body (String, nil) (defaults to: nil)
  • metadata (PlanMyStuff::IssueMetadata, Hash, nil) (defaults to: nil)
  • labels (Array<String>, nil) (defaults to: nil)
  • state (Symbol, nil) (defaults to: nil)

    :open or :closed

  • assignees (Array<String>, String, nil) (defaults to: nil)

    GitHub logins

Returns:

  • (Object)


128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/plan_my_stuff/issue.rb', line 128

def update!(
  number:,
  repo: nil,
  title: nil,
  body: nil,
  metadata: nil,
  labels: nil,
  state: nil,
  assignees: nil
)
  client = PlanMyStuff.client
  resolved_repo = client.resolve_repo(repo)

  options = {}
  options[:title] = title unless title.nil?
  options[:labels] = labels unless labels.nil?
  options[:state] = state.to_s unless state.nil?
  options[:assignees] = Array.wrap(assignees) unless assignees.nil?

  case 
  when PlanMyStuff::IssueMetadata
    .validate_custom_fields!
    options[:body] = MetadataParser.serialize(.to_h, '')
  when Hash
    current = client.rest(:issue, resolved_repo, number)
    current_body = current.respond_to?(:body) ? current.body : current[:body]
    parsed = MetadataParser.parse(current_body)
     = parsed[:metadata]

    merged_custom_fields = ([:custom_fields] || {}).merge([:custom_fields] || {})
     = .merge()
    [:custom_fields] = merged_custom_fields
    PlanMyStuff::CustomFields.new(
      PlanMyStuff.configuration.custom_fields_for(:issue),
      merged_custom_fields,
    ).validate!

    options[:body] = MetadataParser.serialize(, '')
  end

  update_body_comment(number, resolved_repo, body) if body

  return if options.none?

  result = client.rest(:update_issue, resolved_repo, number, **options)
  store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
  result
end

Instance Method Details

#add_blocker!(target) ⇒ PlanMyStuff::Link

Records that target blocks self. Native GitHub action; notifications are handled by GitHub itself.

Parameters:

Returns:



903
904
905
906
907
908
909
910
911
912
913
914
915
# File 'lib/plan_my_stuff/issue.rb', line 903

def add_blocker!(target)
  link = build_link(target, type: :blocked_by)
  validate_not_self!(link)

  target_issue = resolve_target_issue(target, type: :blocked_by)
  PlanMyStuff.client.rest(
    :post,
    dependency_path('blocked_by'),
    { issue_id: target_issue.__send__(:require_github_id!) },
  )
  invalidate_links_cache!
  link
end

#add_related!(target, user: nil, reciprocal: false) ⇒ PlanMyStuff::Link

Adds a :related link to target and, unless this call is already a reciprocal, mirrors the link back on target so the pairing is symmetric. Dedups on (type, issue_number, repo) - re-adding is a no-op.

Parameters:

  • target (PlanMyStuff::Issue, PlanMyStuff::Link, Hash)
  • user (Object, nil) (defaults to: nil)

    actor for notification events

  • reciprocal (Boolean) (defaults to: false)

    internal flag; set by the mirror call

Returns:



763
764
765
766
767
768
769
770
771
772
773
774
775
776
# File 'lib/plan_my_stuff/issue.rb', line 763

def add_related!(target, user: nil, reciprocal: false)
  link = build_link(target, type: :related)
  validate_not_self!(link)

  existing = current_links
  return link if existing.include?(link)

  persist_links!(existing + [link])
  unless reciprocal
    mirror_on_target(link, user: user) { |other| other.add_related!(self, user: user, reciprocal: true) }
  end

  link
end

#add_sub_issue!(target) ⇒ PlanMyStuff::Link

Adds target as a sub-issue of self via POST /issues/{number}/sub_issues. Native GitHub action; notifications are handled by GitHub itself.

Parameters:

Returns:



830
831
832
# File 'lib/plan_my_stuff/issue.rb', line 830

def add_sub_issue!(target)
  mutate_sub_issue!(target, method: :post, path: sub_issues_path)
end

#add_viewers(user_ids:, user: nil) ⇒ Array<Integer>

Adds user IDs to this issue’s visibility allowlist (non-support users whose ID is in the allowlist can see internal comments).

Fires plan_my_stuff.issue.viewers_added.

Parameters:

  • user_ids (Array<Integer>, Integer)
  • user (Object, nil) (defaults to: nil)

    actor for the notification event

Returns:

  • (Array<Integer>)

    the new allowlist



331
332
333
334
335
336
# File 'lib/plan_my_stuff/issue.rb', line 331

def add_viewers(user_ids:, user: nil)
  ids = Array.wrap(user_ids)
  modify_allowlist { |allowlist| allowlist | ids }
  PlanMyStuff::Notifications.instrument('issue.viewers_added', self, user: user, user_ids: ids)
  .visibility_allowlist
end

#approvals_required?Boolean

Returns true when at least one approver is required on this issue.

Returns:

  • (Boolean)

    true when at least one approver is required on this issue



312
313
314
# File 'lib/plan_my_stuff/issue.rb', line 312

def approvals_required?
  approvers.any?
end

#approve!(user:) ⇒ PlanMyStuff::Approval

Flips the caller’s approval from pending to approved. Only the approver themselves may call this. Fires plan_my_stuff.issue.approval_granted and, when this flip completes the approval set, plan_my_stuff.issue.all_approved.

Parameters:

  • user (Object, Integer)

    actor; must resolve to an approver currently pending

Returns:

Raises:



422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/plan_my_stuff/issue.rb', line 422

def approve!(user:)
  actor_id = resolve_actor_id!(user)

  just_approved, was_fully_approved = modify_approvals do |current|
    approval = current.find { |a| a.user_id == actor_id }
    raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
    raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") unless approval.pending?

    approval.status = 'approved'
    approval.approved_at = Time.current
    [current, approval]
  end

  finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved)
  just_approved
end

#approversArray<PlanMyStuff::Approval>

Returns all required approvers (pending + approved).

Returns:



302
303
304
# File 'lib/plan_my_stuff/issue.rb', line 302

def approvers
  .approvals
end

#archive!(now: Time.now.utc) ⇒ self

Tags the issue with the configured archived_label, removes it from every Projects V2 board it belongs to, locks its conversation on GitHub, and stamps metadata.archived_at. Emits plan_my_stuff.issue.archived on success.

No-op (no network calls, no event) when the issue is already archived (either metadata.archived_at is set or the archived label is already on the issue).

Parameters:

  • now (Time) (defaults to: Time.now.utc)

    clock reference for metadata.archived_at

Returns:

  • (self)


587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
# File 'lib/plan_my_stuff/issue.rb', line 587

def archive!(now: Time.now.utc)
  label = PlanMyStuff.configuration.archived_label
  return self unless state == 'closed'

  return self if .archived_at.present?
  return self if labels.include?(label)

  self.class.update!(
    number: number,
    repo: repo,
    metadata: { archived_at: now.utc.iso8601 },
  )

  PlanMyStuff::Label.ensure!(repo: repo, name: label)
  PlanMyStuff::Label.add(issue: self, labels: [label])

  remove_from_all_projects!

  PlanMyStuff.client.rest(:lock_issue, repo.full_name, number)

  reload

  PlanMyStuff::Notifications.instrument(
    'issue.archived',
    self,
    reason: :aged_closed,
  )
  self
end

#blocked_byArray<PlanMyStuff::Issue>

Lazy-memoized issues that block self (i.e. self is blocked by each returned issue) via GitHub’s native issue-dependency REST API.

Returns:



884
885
886
# File 'lib/plan_my_stuff/issue.rb', line 884

def blocked_by
  links_cache[:blocked_by] ||= fetch_dependencies('blocked_by')
end

#blockingArray<PlanMyStuff::Issue>

Lazy-memoized issues that self blocks.

Returns:



892
893
894
# File 'lib/plan_my_stuff/issue.rb', line 892

def blocking
  links_cache[:blocking] ||= fetch_dependencies('blocking')
end

#bodyString?

Returns the issue body content. For PMS issues, this is the body from the body comment (stripped of its header). Falls back to the parsed issue body for non-PMS issues.

Returns:

  • (String, nil)


35
# File 'lib/plan_my_stuff/issue.rb', line 35

attribute :body, :string

#body=(value) ⇒ String

Assigning a new body marks the instance dirty so the next save! rewrites the backing PMS body comment. Unsaved assignments are reflected by #body until persisted or reloaded.

Parameters:

  • value (String)

Returns:

  • (String)


296
297
298
299
# File 'lib/plan_my_stuff/issue.rb', line 296

def body=(value)
  super
  @body_dirty = true
end

#body_commentPlanMyStuff::Comment?

Returns the comment marked as the issue body, if any.

Returns:



706
707
708
# File 'lib/plan_my_stuff/issue.rb', line 706

def body_comment
  pms_comments.find { |c| c..issue_body? }
end

#clear_waiting_on_user!self

Clears the waiting-on-user state: removes the label, clears metadata.waiting_on_user_at, and clears metadata.next_reminder_at unless a waiting-on-approval timer is still active. No-ops if the issue is not currently waiting on a user reply.

Returns:

  • (self)


524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'lib/plan_my_stuff/issue.rb', line 524

def clear_waiting_on_user!
  label = PlanMyStuff.configuration.waiting_on_user_label
  return self if .waiting_on_user_at.nil? && labels.exclude?(label)

  PlanMyStuff::Label.remove(issue: self, labels: [label]) if labels.include?(label)

  self.class.update!(
    number: number,
    repo: repo,
    metadata: {
      waiting_on_user_at: nil,
      next_reminder_at: .waiting_on_approval_at ? format_time(.next_reminder_at) : nil,
    },
  )
  reload
end

#closed_atTime?

Returns GitHub’s closed_at timestamp (nil while open).

Returns:

  • (Time, nil)

    GitHub’s closed_at timestamp (nil while open)



27
# File 'lib/plan_my_stuff/issue.rb', line 27

attribute :closed_at

#commentsArray<PlanMyStuff::Comment>

Lazy-loads and memoizes comments from the GitHub API.

Returns:



680
681
682
# File 'lib/plan_my_stuff/issue.rb', line 680

def comments
  @comments ||= load_comments
end

#duplicate_ofPlanMyStuff::Issue?

Lazy-memoized issue that self was marked as duplicate of, via GitHub’s native close-as-duplicate. Returns nil for issues that are open or closed for other reasons.

Returns:



942
943
944
945
946
# File 'lib/plan_my_stuff/issue.rb', line 942

def duplicate_of
  return links_cache[:duplicate_of] if links_cache.key?(:duplicate_of)

  links_cache[:duplicate_of] = fetch_duplicate_of
end

#enter_waiting_on_user!(user: nil) ⇒ self

Marks the issue as waiting on an end-user reply. Sets metadata.waiting_on_user_at to now, (re)computes metadata.next_reminder_at, and adds the configured waiting_on_user_label to the issue. Called from Comment.create! when a support user posts a comment with waiting_on_reply: true, and from the Issues::WaitingsController toggle.

Parameters:

  • user (Object, nil) (defaults to: nil)

    actor for the label notification event

Returns:

  • (self)


498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
# File 'lib/plan_my_stuff/issue.rb', line 498

def enter_waiting_on_user!(user: nil)
  now = Time.now.utc
  label = PlanMyStuff.configuration.waiting_on_user_label

  PlanMyStuff::Label.ensure!(repo: repo, name: label)
  PlanMyStuff::Label.add(issue: self, labels: [label], user: user) if labels.exclude?(label)

  self.class.update!(
    number: number,
    repo: repo,
    metadata: {
      waiting_on_user_at: now.iso8601,
      next_reminder_at: format_next_reminder_at(from: now),
    },
  )
  reload
end

#fully_approved?Boolean

Returns true when approvers are required AND every approver has approved.

Returns:

  • (Boolean)

    true when approvers are required AND every approver has approved



317
318
319
# File 'lib/plan_my_stuff/issue.rb', line 317

def fully_approved?
  approvals_required? && pending_approvals.empty?
end

#github_idInteger?

GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue number).

Returns:

  • (Integer, nil)


1001
1002
1003
# File 'lib/plan_my_stuff/issue.rb', line 1001

def github_id
  safe_read_field(github_response, :id)
end

#github_node_idString?

GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.

Returns:

  • (String, nil)


992
993
994
# File 'lib/plan_my_stuff/issue.rb', line 992

def github_node_id
  safe_read_field(github_response, :node_id)
end

#html_urlString?

GitHub web URL for this issue, for escape-hatch “View on GitHub” links.

Returns:

  • (String, nil)


688
689
690
# File 'lib/plan_my_stuff/issue.rb', line 688

def html_url
  safe_read_field(github_response, :html_url)
end

#labelsArray<String>

Returns label names.

Returns:

  • (Array<String>)

    label names



23
# File 'lib/plan_my_stuff/issue.rb', line 23

attribute :labels, default: -> { [] }

#lockedBoolean Also known as: locked?

Returns GitHub’s locked flag; true for archived or manually-locked issues (no new comments).

Returns:

  • (Boolean)

    GitHub’s locked flag; true for archived or manually-locked issues (no new comments)



30
# File 'lib/plan_my_stuff/issue.rb', line 30

attribute :locked, :boolean, default: false

#mark_duplicate!(target, user: nil) ⇒ PlanMyStuff::Link

Closes self as a duplicate of target via GitHub’s native close-as-duplicate, carrying over viewers, assignees, and a back-pointer comment on the target.

Side effects, in order:

  1. Resolves target; raises ValidationError if missing.

  2. Raises ValidationError when self is already closed.

  3. Merges self’s visibility_allowlist onto target.

  4. Merges self’s assignees onto target.

  5. Posts a PMS comment on target with the back-pointer.

  6. Closes self with state_reason: :duplicate and duplicate_of: { owner:, repo:, number: }.

  7. Reloads self; invalidates link caches.

  8. Fires plan_my_stuff.issue.marked_duplicate.

Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.

Parameters:

Returns:

Raises:



971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
# File 'lib/plan_my_stuff/issue.rb', line 971

def mark_duplicate!(target, user: nil)
  raise(PlanMyStuff::ValidationError, 'Cannot mark a closed issue as duplicate') if state == 'closed'

  target_issue = resolve_duplicate_target!(target)
  merge_visibility_allowlist_onto(target_issue)
  merge_assignees_onto(target_issue)
  post_duplicate_back_pointer(target_issue, user: user)
  close_as_duplicate!(target_issue)

  reload
  invalidate_links_cache!
  PlanMyStuff::Notifications.instrument('issue.marked_duplicate', self, target: target_issue, user: user)

  build_link(target_issue, type: :duplicate_of)
end

#metadataPlanMyStuff::IssueMetadata

Returns parsed metadata (empty when no PMS metadata present).

Returns:



17
# File 'lib/plan_my_stuff/issue.rb', line 17

attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new }

#numberInteger?

Returns GitHub issue number.

Returns:

  • (Integer, nil)

    GitHub issue number



13
# File 'lib/plan_my_stuff/issue.rb', line 13

attribute :number, :integer

#parentPlanMyStuff::Issue?

Lazy-memoized parent issue via GitHub’s native sub-issues API. GitHub enforces at most one parent per issue.

Returns:



808
809
810
811
812
# File 'lib/plan_my_stuff/issue.rb', line 808

def parent
  return links_cache[:parent] if links_cache.key?(:parent)

  links_cache[:parent] = fetch_parent
end

#pending_approvalsArray<PlanMyStuff::Approval>

Returns approvers who have not yet approved.

Returns:



307
308
309
# File 'lib/plan_my_stuff/issue.rb', line 307

def pending_approvals
  approvers.select(&:pending?)
end

#pms_commentsArray<PlanMyStuff::Comment>

Returns only comments created via PMS.

Returns:



698
699
700
# File 'lib/plan_my_stuff/issue.rb', line 698

def pms_comments
  comments.select(&:pms_comment?)
end

#pms_issue?Boolean

Returns:

  • (Boolean)


693
694
695
# File 'lib/plan_my_stuff/issue.rb', line 693

def pms_issue?
  .schema_version.present?
end

#raw_bodyString?

Returns full body as stored on GitHub.

Returns:

  • (String, nil)

    full body as stored on GitHub



15
# File 'lib/plan_my_stuff/issue.rb', line 15

attribute :raw_body, :string

Lazy-memoized array of Issue objects for :related links. Silently drops targets that 404 so a dangling pointer doesn’t break the rest of the list.

Returns:



748
749
750
# File 'lib/plan_my_stuff/issue.rb', line 748

def related
  links_cache[:related] ||= fetch_related
end

#reloadself

Re-fetches this issue from GitHub and updates all local attributes.

Returns:

  • (self)


670
671
672
673
674
# File 'lib/plan_my_stuff/issue.rb', line 670

def reload
  fresh = self.class.find(number, repo: repo)
  hydrate_from_issue(fresh)
  self
end

#remove_approvers!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>

Removes approvers from this issue’s required-approvals list. Only support users may call this. Removing a pending approver may flip the issue into fully_approved? (fires all_approved). Removing an approved approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue no longer has approvals_required?).

Parameters:

  • user_ids (Array<Integer>, Integer)
  • user (Object, nil) (defaults to: nil)

    actor; must be a support user

Returns:

Raises:



398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/plan_my_stuff/issue.rb', line 398

def remove_approvers!(user_ids:, user: nil)
  guard_support!(user)
  ids = Array.wrap(user_ids).map(&:to_i)

  just_removed, was_fully_approved = modify_approvals do |current|
    removed = current.select { |a| ids.include?(a.user_id) }
    [current - removed, removed]
  end

  emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user)
  just_removed
end

#remove_blocker!(target) ⇒ PlanMyStuff::Link

Removes the record that target blocks self.

Parameters:

Returns:



923
924
925
926
927
928
929
930
931
932
933
934
# File 'lib/plan_my_stuff/issue.rb', line 923

def remove_blocker!(target)
  link = build_link(target, type: :blocked_by)
  validate_not_self!(link)

  target_issue = resolve_target_issue(target, type: :blocked_by)
  PlanMyStuff.client.rest(
    :delete,
    "#{dependency_path('blocked_by')}/#{target_issue.__send__(:require_github_id!)}",
  )
  invalidate_links_cache!
  link
end

#remove_parent!PlanMyStuff::Link?

Detaches self from its current parent, if any. Returns the Link that was removed, or nil when there was no parent.

Returns:



868
869
870
871
872
873
874
875
876
# File 'lib/plan_my_stuff/issue.rb', line 868

def remove_parent!
  current = parent
  return if current.nil?

  current.remove_sub_issue!(self)
  invalidate_links_cache!

  build_link(current, type: :parent)
end

#remove_related!(target, user: nil, reciprocal: false) ⇒ PlanMyStuff::Link

Removes a :related link to target and, unless this call is already a reciprocal, mirrors the removal on target. No-op when the link isn’t present locally.

Parameters:

Returns:



788
789
790
791
792
793
794
795
796
797
798
799
800
801
# File 'lib/plan_my_stuff/issue.rb', line 788

def remove_related!(target, user: nil, reciprocal: false)
  link = build_link(target, type: :related)
  validate_not_self!(link)

  existing = current_links
  return link if existing.exclude?(link)

  persist_links!(existing.reject { |l| l == link })
  unless reciprocal
    mirror_on_target(link, user: user) { |other| other.remove_related!(self, user: user, reciprocal: true) }
  end

  link
end

#remove_sub_issue!(target) ⇒ PlanMyStuff::Link

Removes target as a sub-issue of self via DELETE /issues/{number}/sub_issue (singular).

Parameters:

Returns:



841
842
843
# File 'lib/plan_my_stuff/issue.rb', line 841

def remove_sub_issue!(target)
  mutate_sub_issue!(target, method: :delete, path: remove_sub_issue_path)
end

#remove_viewers(user_ids:, user: nil) ⇒ Array<Integer>

Removes user IDs from this issue’s visibility allowlist.

Fires plan_my_stuff.issue.viewers_removed.

Parameters:

  • user_ids (Array<Integer>, Integer)
  • user (Object, nil) (defaults to: nil)

    actor for the notification event

Returns:

  • (Array<Integer>)

    the new allowlist



347
348
349
350
351
352
# File 'lib/plan_my_stuff/issue.rb', line 347

def remove_viewers(user_ids:, user: nil)
  ids = Array.wrap(user_ids)
  modify_allowlist { |allowlist| allowlist - ids }
  PlanMyStuff::Notifications.instrument('issue.viewers_removed', self, user: user, user_ids: ids)
  .visibility_allowlist
end

#reopen_by_reply!(comment:, user: nil) ⇒ self

Reopens an issue that was auto-closed by the inactivity sweep, clears metadata.closed_by_inactivity, and emits plan_my_stuff.issue.reopened_by_reply carrying the reopening comment. Does not emit the regular issue.reopened event - subscribers that specifically care about this flow subscribe to the dedicated event.

Parameters:

  • comment (PlanMyStuff::Comment)

    the reopening comment

  • user (Object, nil) (defaults to: nil)

    actor for the notification event

Returns:

  • (self)


553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/plan_my_stuff/issue.rb', line 553

def reopen_by_reply!(comment:, user: nil)
  inactive_label = PlanMyStuff.configuration.user_inactive_label
  PlanMyStuff::Label.remove(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)

  self.class.update!(
    number: number,
    repo: repo,
    state: :open,
    metadata: { closed_by_inactivity: false },
  )
  reload

  PlanMyStuff::Notifications.instrument(
    'issue.reopened_by_reply',
    self,
    user: user,
    comment: comment,
  )
  self
end

#repoPlanMyStuff::Repo?

Returns:



33
# File 'lib/plan_my_stuff/issue.rb', line 33

attribute :repo

#repo=(value) ⇒ Object

Parameters:



283
284
285
# File 'lib/plan_my_stuff/issue.rb', line 283

def repo=(value)
  super(value.present? ? PlanMyStuff::Repo.resolve(value) : nil)
end

#request_approvals!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>

Adds approvers to this issue’s required-approvals list. Idempotent: users already present are no-ops. Only support users may call this.

Fires plan_my_stuff.issue.approval_requested when any user is newly added. Also fires plan_my_stuff.issue.approvals_invalidated (+trigger: :approver_added+) when the new approvers flip the issue out of a fully-approved state.

Parameters:

  • user_ids (Array<Integer>, Integer)
  • user (Object, nil) (defaults to: nil)

    actor; must be a support user

Returns:

Raises:



369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/plan_my_stuff/issue.rb', line 369

def request_approvals!(user_ids:, user: nil)
  guard_support!(user)
  ids = Array.wrap(user_ids).map(&:to_i)

  just_added, was_fully_approved = modify_approvals do |current|
    existing_ids = current.map(&:user_id)
    new_ids = ids - existing_ids
    added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
    [current + added, added]
  end

  finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved)
  just_added
end

#revoke_approval!(user:, target_user_id: nil) ⇒ PlanMyStuff::Approval

Flips an approved record back to pending. Approvers may revoke their own approval; support users may revoke any approver’s approval by passing target_user_id:. Non-support callers passing a target_user_id: that is not their own raise AuthorizationError.

Fires plan_my_stuff.issue.approval_revoked and, when this flip drops the issue out of fully_approved?, plan_my_stuff.issue.approvals_invalidated (+trigger: :revoked+).

Parameters:

  • user (Object, Integer)

    the caller

  • target_user_id (Integer, nil) (defaults to: nil)

    approver whose approval should be revoked; defaults to the caller

Returns:

Raises:



457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/plan_my_stuff/issue.rb', line 457

def revoke_approval!(user:, target_user_id: nil)
  actor_id = resolve_actor_id!(user)
  caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
  target_id = target_user_id&.to_i || actor_id

  if !caller_is_support && target_id != actor_id
    raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's approval")
  end

  just_revoked, was_fully_approved = modify_approvals do |current|
    approval = current.find { |a| a.user_id == target_id }
    raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
    raise(PlanMyStuff::ValidationError, "User #{target_id} is not currently approved") unless approval.approved?

    approval.status = 'pending'
    approval.approved_at = nil
    [current, approval]
  end

  finish_state_change(
    :approval_revoked,
    just_revoked,
    user: user,
    was_fully_approved: was_fully_approved,
    trigger: :revoked,
  )
  just_revoked
end

#save!(user: nil, skip_notification: false) ⇒ self

Persists the issue. Creates if new, otherwise performs a full write: serializes @metadata into the GitHub issue body and PATCHes title/state/labels. When #body= has been called since the last load, also rewrites the PMS body comment. Always reloads afterwards.

Returns:

  • (self)

Raises:



627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# File 'lib/plan_my_stuff/issue.rb', line 627

def save!(user: nil, skip_notification: false)
  if new_record?
    created = self.class.create!(
      title: title,
      body: body,
      repo: repo,
      labels: labels || [],
      user: user || .created_by,
      metadata: .custom_fields.to_h,
      visibility: .visibility,
      visibility_allowlist: Array.wrap(.visibility_allowlist),
    )
    hydrate_from_issue(created)
  else
    captured_changes = changes.dup
    persist_update!
    instrument_update(captured_changes, user) unless skip_notification
  end

  self
end

#set_parent!(target) ⇒ PlanMyStuff::Link

Makes target the parent of self. If self already has a parent, it is detached first. Returns a Link describing the new :parent relationship.

Parameters:

Returns:



853
854
855
856
857
858
859
860
861
# File 'lib/plan_my_stuff/issue.rb', line 853

def set_parent!(target)
  parent.presence&.remove_sub_issue!(self)

  target_issue = resolve_target_issue(target, type: :parent)
  target_issue.add_sub_issue!(self)
  invalidate_links_cache!

  build_link(target_issue, type: :parent)
end

#stateString?

Returns issue state (“open” or “closed”).

Returns:

  • (String, nil)

    issue state (“open” or “closed”)



21
# File 'lib/plan_my_stuff/issue.rb', line 21

attribute :state, :string

#sub_ticketsArray<PlanMyStuff::Issue>

Lazy-memoized sub-issues via GitHub’s native sub-issues API.

Returns:



818
819
820
# File 'lib/plan_my_stuff/issue.rb', line 818

def sub_tickets
  links_cache[:sub_tickets] ||= fetch_sub_tickets
end

#titleString?

Returns issue title.

Returns:

  • (String, nil)

    issue title



19
# File 'lib/plan_my_stuff/issue.rb', line 19

attribute :title, :string

#update!(user: nil, skip_notification: false, **attrs) ⇒ self

Applies attrs to this instance in-memory then calls save!. Supports title:, body:, state:, labels:, assignees:, and metadata:. The metadata: kwarg is a hash whose keys are merged into the existing metadata (top-level attributes assigned directly; :custom_fields merged key-by-key).

Parameters:

  • user (Object, nil) (defaults to: nil)

    actor for notification events

Returns:

  • (self)

Raises:



661
662
663
664
# File 'lib/plan_my_stuff/issue.rb', line 661

def update!(user: nil, skip_notification: false, **attrs)
  apply_update_attrs!(attrs)
  save!(user: user, skip_notification: skip_notification)
end

#updated_atTime?

Returns GitHub’s updated_at timestamp.

Returns:

  • (Time, nil)

    GitHub’s updated_at timestamp



25
# File 'lib/plan_my_stuff/issue.rb', line 25

attribute :updated_at

#visible_to?(user) ⇒ Boolean

Delegates visibility check to metadata. Non-PMS issues are always visible.

Parameters:

  • user (Object, Integer)

    user object or user_id

Returns:

  • (Boolean)


734
735
736
737
738
739
740
# File 'lib/plan_my_stuff/issue.rb', line 734

def visible_to?(user)
  if pms_issue?
    .visible_to?(user)
  else
    UserResolver.support?(UserResolver.resolve(user))
  end
end