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.



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

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
105
106
107
108
109
110
111
112
113
114
115
# 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)

  link_body = visible_body_for(number, resolved_repo)
  if link_body.present?
    result = client.rest(
      :update_issue,
      resolved_repo,
      number,
      body: MetadataParser.serialize(.to_h, link_body),
    )
    store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
  end

  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:



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/plan_my_stuff/issue.rb', line 195

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:



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/plan_my_stuff/issue.rb', line 226

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)


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
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/plan_my_stuff/issue.rb', line 139

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, visible_body_for(number, resolved_repo))
  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(, visible_body_for(number, resolved_repo))
  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:



956
957
958
959
960
961
962
963
964
965
966
967
968
# File 'lib/plan_my_stuff/issue.rb', line 956

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:



816
817
818
819
820
821
822
823
824
825
826
827
828
829
# File 'lib/plan_my_stuff/issue.rb', line 816

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:



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

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



376
377
378
379
380
381
# File 'lib/plan_my_stuff/issue.rb', line 376

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



357
358
359
# File 'lib/plan_my_stuff/issue.rb', line 357

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:



467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/plan_my_stuff/issue.rb', line 467

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:



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

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)


632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
# File 'lib/plan_my_stuff/issue.rb', line 632

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

#assigneesArray<String>

GitHub assignees for this issue, by login.

Returns:

  • (Array<String>)


741
742
743
# File 'lib/plan_my_stuff/issue.rb', line 741

def assignees
  extract_assignee_logins(github_response)
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:



937
938
939
# File 'lib/plan_my_stuff/issue.rb', line 937

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

#blockingArray<PlanMyStuff::Issue>

Lazy-memoized issues that self blocks.

Returns:



945
946
947
# File 'lib/plan_my_stuff/issue.rb', line 945

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)


327
328
329
330
# File 'lib/plan_my_stuff/issue.rb', line 327

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

#body_commentPlanMyStuff::Comment?

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

Returns:



759
760
761
# File 'lib/plan_my_stuff/issue.rb', line 759

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)


569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'lib/plan_my_stuff/issue.rb', line 569

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:



725
726
727
# File 'lib/plan_my_stuff/issue.rb', line 725

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:



995
996
997
998
999
# File 'lib/plan_my_stuff/issue.rb', line 995

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)


543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
# File 'lib/plan_my_stuff/issue.rb', line 543

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



362
363
364
# File 'lib/plan_my_stuff/issue.rb', line 362

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)


1054
1055
1056
# File 'lib/plan_my_stuff/issue.rb', line 1054

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)


1045
1046
1047
# File 'lib/plan_my_stuff/issue.rb', line 1045

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)


733
734
735
# File 'lib/plan_my_stuff/issue.rb', line 733

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:



1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
# File 'lib/plan_my_stuff/issue.rb', line 1024

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:



861
862
863
864
865
# File 'lib/plan_my_stuff/issue.rb', line 861

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:



352
353
354
# File 'lib/plan_my_stuff/issue.rb', line 352

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

#pms_commentsArray<PlanMyStuff::Comment>

Returns only comments created via PMS.

Returns:



751
752
753
# File 'lib/plan_my_stuff/issue.rb', line 751

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

#pms_issue?Boolean

Returns:

  • (Boolean)


746
747
748
# File 'lib/plan_my_stuff/issue.rb', line 746

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:



801
802
803
# File 'lib/plan_my_stuff/issue.rb', line 801

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

#reloadself

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

Returns:

  • (self)


715
716
717
718
719
# File 'lib/plan_my_stuff/issue.rb', line 715

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:



443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/plan_my_stuff/issue.rb', line 443

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:



976
977
978
979
980
981
982
983
984
985
986
987
# File 'lib/plan_my_stuff/issue.rb', line 976

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:



921
922
923
924
925
926
927
928
929
# File 'lib/plan_my_stuff/issue.rb', line 921

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:



841
842
843
844
845
846
847
848
849
850
851
852
853
854
# File 'lib/plan_my_stuff/issue.rb', line 841

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:



894
895
896
# File 'lib/plan_my_stuff/issue.rb', line 894

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



392
393
394
395
396
397
# File 'lib/plan_my_stuff/issue.rb', line 392

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)


598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
# File 'lib/plan_my_stuff/issue.rb', line 598

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:



314
315
316
# File 'lib/plan_my_stuff/issue.rb', line 314

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:



414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/plan_my_stuff/issue.rb', line 414

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:



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
# File 'lib/plan_my_stuff/issue.rb', line 502

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:



672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'lib/plan_my_stuff/issue.rb', line 672

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:



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

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:



871
872
873
# File 'lib/plan_my_stuff/issue.rb', line 871

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:



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

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

Returns per-issue URL in the consuming app (config.issues_url_prefix + “/” + number + “?repo=Org/Repo”, or nil when either prefix or number is missing). Also rendered as the destination of the markdown link in the GitHub issue body.

Returns:

  • (String, nil)

    per-issue URL in the consuming app (config.issues_url_prefix + “/” + number + “?repo=Org/Repo”, or nil when either prefix or number is missing). Also rendered as the destination of the markdown link in the GitHub issue body.



336
337
338
339
340
341
342
343
344
# File 'lib/plan_my_stuff/issue.rb', line 336

def user_link
  prefix = PlanMyStuff.configuration.issues_url_prefix
  return if prefix.blank? || number.blank?

  base = "#{prefix.to_s.chomp('/')}/#{number}"
  return base if repo.blank?

  "#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}"
end

#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)


787
788
789
790
791
792
793
# File 'lib/plan_my_stuff/issue.rb', line 787

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