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.



458
459
460
461
# File 'lib/plan_my_stuff/issue.rb', line 458

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

Class Method Details

.check_import!(import_id, repo: nil) ⇒ Hash

Polls a previously-submitted import for its current status.

Parameters:

  • import_id (Integer)

    id from the Issue.import response

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

    defaults to config.default_repo

Returns:

  • (Hash)

    parsed status response

Raises:



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

def check_import!(import_id, repo: nil)
  client = PlanMyStuff.import_client
  resolved_repo = client.resolve_repo!(repo)

  client.octokit.get(
    "/repos/#{resolved_repo}/import/issues/#{import_id}",
    accept: 'application/vnd.github.golden-comet-preview+json',
  )
rescue Octokit::ClientError, Octokit::ServerError => e
  raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
end

.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, visibility: 'public', visibility_allowlist: [], issue_type: nil) ⇒ 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

  • issue_type (String, nil) (defaults to: nil)

    GitHub issue type name (e.g. “Bug”, “Feature”). Must match a type configured on the org. nil creates the issue with no type.

Returns:

Raises:



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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/plan_my_stuff/issue.rb', line 80

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

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

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

  serialized_body = PlanMyStuff::MetadataParser.serialize!(.to_h, '')

  resolved_type = resolve_issue_type!(issue_type)

  options = {}
  options[:labels] = labels if labels.present?
  options[:type] = resolved_type if resolved_type.present?

  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: PlanMyStuff::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)
    PlanMyStuff::ProjectItem.create!(issue, project_number: project_number)
  end

  PlanMyStuff::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:

Raises:

  • (Octokit::NotFound)

    when the issue number resolves to a pull request



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/plan_my_stuff/issue.rb', line 238

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

  pull_request =
    if github_issue.respond_to?(:pull_request)
      github_issue.pull_request
    elsif github_issue.is_a?(Hash)
      github_issue[:pull_request] || github_issue['pull_request']
    end

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

  build(github_issue, repo: resolved_repo)
end

.import!(payloads) ⇒ Array<Hash>

Submits one or more pre-built payloads to GitHub’s “Import Issues” preview endpoint (+POST /repos/:repo/import/issues+). One request per payload: the endpoint only accepts a single {issue:, comments:} payload at a time.

Each payload hash MUST include a :repo key (symbol, string, or PlanMyStuff::Repo) and the GitHub-shaped :issue /+ :comments+ keys; :repo is extracted before the POST. Payloads are passed through to GitHub unchanged otherwise - callers are responsible for shape, encoding, and any PlanMyStuff metadata they want to embed.

The endpoint is async: each response carries an id and url for polling via Issue.check_import.

Parameters:

  • payloads (Array<Hash>, Hash)

Returns:

  • (Array<Hash>)

    one parsed status hash per input payload, in input order

Raises:

  • (ArgumentError)

    when the import payload is missing :repo



314
315
316
317
318
319
320
321
322
323
324
# File 'lib/plan_my_stuff/issue.rb', line 314

def import!(payloads)
  client = PlanMyStuff.import_client

  Array.wrap(payloads).map do |payload|
    repo = payload[:repo] || payload['repo'] || PlanMyStuff.configuration.default_repo
    raise(ArgumentError, 'import payload must include :repo') if repo.blank?

    body = payload.except(:repo, 'repo')
    submit_import_request!(client, client.resolve_repo!(repo), body)
  end
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:



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/plan_my_stuff/issue.rb', line 276

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.present?

  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, issue_type: ISSUE_TYPE_UNCHANGED) ⇒ 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

  • issue_type (String, nil) (defaults to: ISSUE_TYPE_UNCHANGED)

    GitHub issue type name. Pass a String to set, nil to clear, or omit the kwarg to leave the current type untouched. (nil-vs-omitted is differentiated by the private ISSUE_TYPE_UNCHANGED sentinel.)

Returns:

  • (Object)


176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/plan_my_stuff/issue.rb', line 176

def update!(
  number:,
  repo: nil,
  title: nil,
  body: nil,
  metadata: nil,
  labels: nil,
  state: nil,
  assignees: nil,
  issue_type: ISSUE_TYPE_UNCHANGED
)
  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?
  options[:type] = resolve_issue_type!(issue_type) unless issue_type.equal?(ISSUE_TYPE_UNCHANGED)

  case 
  when PlanMyStuff::IssueMetadata
    .validate_custom_fields!
    options[:body] =
      PlanMyStuff::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 = PlanMyStuff::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] =
      PlanMyStuff::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:



1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
# File 'lib/plan_my_stuff/issue.rb', line 1115

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:



982
983
984
985
986
987
988
989
990
991
992
993
994
995
# File 'lib/plan_my_stuff/issue.rb', line 982

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:



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

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



530
531
532
533
534
535
# File 'lib/plan_my_stuff/issue.rb', line 530

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



510
511
512
# File 'lib/plan_my_stuff/issue.rb', line 510

def approvals_required?
  approvers.present?
end

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

Flips the caller’s approval to approved from any other state (pending or rejected). 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

Returns:

Raises:



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
# File 'lib/plan_my_stuff/issue.rb', line 613

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") if approval.approved?

    approval.status = 'approved'
    approval.approved_at = Time.current
    approval.rejected_at = nil
    [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 + rejected).

Returns:



494
495
496
# File 'lib/plan_my_stuff/issue.rb', line 494

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)


809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
# File 'lib/plan_my_stuff/issue.rb', line 809

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: PlanMyStuff.format_time(now) },
  )

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


911
912
913
# File 'lib/plan_my_stuff/issue.rb', line 911

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:



1097
1098
1099
# File 'lib/plan_my_stuff/issue.rb', line 1097

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

#blockingArray<PlanMyStuff::Issue>

Lazy-memoized issues that self blocks.

Returns:



1105
1106
1107
# File 'lib/plan_my_stuff/issue.rb', line 1105

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)


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

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)


475
476
477
478
# File 'lib/plan_my_stuff/issue.rb', line 475

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

#body_commentPlanMyStuff::Comment?

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

Returns:



929
930
931
# File 'lib/plan_my_stuff/issue.rb', line 929

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)


751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
# File 'lib/plan_my_stuff/issue.rb', line 751

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 ? PlanMyStuff.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)



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

attribute :closed_at

#commentsArray<PlanMyStuff::Comment>

Lazy-loads and memoizes comments from the GitHub API.

Returns:



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

def comments
  @comments ||= load_comments
end

#created_atTime?

Returns GitHub’s created_at timestamp; settable on unpersisted issues for use with Issue.import.

Returns:

  • (Time, nil)

    GitHub’s created_at timestamp; settable on unpersisted issues for use with Issue.import



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

attribute :created_at

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



1153
1154
1155
1156
1157
# File 'lib/plan_my_stuff/issue.rb', line 1153

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)


727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/plan_my_stuff/issue.rb', line 727

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: PlanMyStuff.format_time(now),
      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. A single rejection blocks this gate until the approver revokes.

Returns:

  • (Boolean)

    true when approvers are required AND every approver has approved. A single rejection blocks this gate until the approver revokes.



516
517
518
# File 'lib/plan_my_stuff/issue.rb', line 516

def fully_approved?
  approvals_required? && approvers.all?(&:approved?)
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)


1211
1212
1213
# File 'lib/plan_my_stuff/issue.rb', line 1211

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)


1202
1203
1204
# File 'lib/plan_my_stuff/issue.rb', line 1202

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)


903
904
905
# File 'lib/plan_my_stuff/issue.rb', line 903

def html_url
  safe_read_field(github_response, :html_url)
end

#issue_typeString?

Returns GitHub issue type name (e.g. “Bug”, “Feature”) or nil when no type is assigned. Read from the nested type.name field on the REST response. Settable via the issue_type: kwarg on Issue.create! / Issue.update!.

Returns:

  • (String, nil)

    GitHub issue type name (e.g. “Bug”, “Feature”) or nil when no type is assigned. Read from the nested type.name field on the REST response. Settable via the issue_type: kwarg on Issue.create! / Issue.update!.



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

attribute :issue_type, :string

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



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

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:



1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
# File 'lib/plan_my_stuff/issue.rb', line 1182

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:



1025
1026
1027
1028
1029
# File 'lib/plan_my_stuff/issue.rb', line 1025

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 acted (pending only; rejections are NOT pending – the approver has responded).

Returns:

  • (Array<PlanMyStuff::Approval>)

    approvers who have not yet acted (pending only; rejections are NOT pending – the approver has responded)



500
501
502
# File 'lib/plan_my_stuff/issue.rb', line 500

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

#pms_commentsArray<PlanMyStuff::Comment>

Returns only comments created via PMS.

Returns:



921
922
923
# File 'lib/plan_my_stuff/issue.rb', line 921

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

#pms_issue?Boolean

Returns:

  • (Boolean)


916
917
918
# File 'lib/plan_my_stuff/issue.rb', line 916

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

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

Flips the caller’s approval to rejected from any other state (pending or approved). Only the approver themselves may call this. Fires plan_my_stuff.issue.approval_rejected and, when this flip drops the issue out of fully_approved? (i.e. the caller was the last approved approver), plan_my_stuff.issue.approvals_invalidated (+trigger: :rejected+).

Parameters:

  • user (Object, Integer)

    actor; must resolve to an approver

Returns:

Raises:



642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
# File 'lib/plan_my_stuff/issue.rb', line 642

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

  just_rejected, 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 rejected") if approval.rejected?

    approval.status = 'rejected'
    approval.rejected_at = Time.current
    approval.approved_at = nil
    [current, approval]
  end

  finish_state_change(
    :approval_rejected,
    just_rejected,
    user: user,
    was_fully_approved: was_fully_approved,
    trigger: :rejected,
  )
  just_rejected
end

#rejected_approvalsArray<PlanMyStuff::Approval>

Returns approvers who have rejected.

Returns:



505
506
507
# File 'lib/plan_my_stuff/issue.rb', line 505

def rejected_approvals
  approvers.select(&:rejected?)
end

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:



969
970
971
# File 'lib/plan_my_stuff/issue.rb', line 969

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

#reloadself

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

Returns:

  • (self)


885
886
887
888
889
# File 'lib/plan_my_stuff/issue.rb', line 885

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:



590
591
592
593
594
595
596
597
598
599
600
601
# File 'lib/plan_my_stuff/issue.rb', line 590

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:



1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
# File 'lib/plan_my_stuff/issue.rb', line 1135

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:



1082
1083
1084
1085
1086
1087
1088
1089
1090
# File 'lib/plan_my_stuff/issue.rb', line 1082

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:



1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
# File 'lib/plan_my_stuff/issue.rb', line 1006

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:



1056
1057
1058
# File 'lib/plan_my_stuff/issue.rb', line 1056

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



546
547
548
549
550
551
# File 'lib/plan_my_stuff/issue.rb', line 546

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)


777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
# File 'lib/plan_my_stuff/issue.rb', line 777

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:



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

attribute :repo

#repo=(value) ⇒ Object

Parameters:



464
465
466
# File 'lib/plan_my_stuff/issue.rb', line 464

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:



565
566
567
568
569
570
571
572
573
574
575
576
577
578
# File 'lib/plan_my_stuff/issue.rb', line 565

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 or rejected record back to pending. Approvers may revoke their own response; support users may revoke any approver’s response by passing target_user_id:. Non-support callers passing a target_user_id: that is not their own raise AuthorizationError.

Emits the granular event keyed off the source state: plan_my_stuff.issue.approval_revoked from approved, or plan_my_stuff.issue.rejection_revoked from rejected. When revoking an approval drops the issue out of fully_approved?, also fires plan_my_stuff.issue.approvals_invalidated (+trigger: :revoked+). Revoking a rejection cannot change fully_approved? (the issue was already gated), so no aggregate event fires.

Parameters:

  • user (Object, Integer)

    the caller

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

    approver whose response should be revoked; defaults to the caller

Returns:

Raises:



683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
# File 'lib/plan_my_stuff/issue.rb', line 683

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 response")
  end

  revoked_from = nil
  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?
    if approval.pending?
      raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded — nothing to revoke")
    end

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

  event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
  finish_state_change(
    event,
    just_revoked,
    user: user,
    was_fully_approved: was_fully_approved,
    trigger: (event == :approval_revoked) ? :revoked : nil,
  )
  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)


845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
# File 'lib/plan_my_stuff/issue.rb', line 845

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),
      issue_type: issue_type,
    )
    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:



1067
1068
1069
1070
1071
1072
1073
1074
1075
# File 'lib/plan_my_stuff/issue.rb', line 1067

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:



1035
1036
1037
# File 'lib/plan_my_stuff/issue.rb', line 1035

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)


876
877
878
879
# File 'lib/plan_my_stuff/issue.rb', line 876

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.



483
484
485
486
487
488
489
490
491
# File 'lib/plan_my_stuff/issue.rb', line 483

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)


956
957
958
959
960
961
962
# File 'lib/plan_my_stuff/issue.rb', line 956

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