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.



454
455
456
457
# File 'lib/plan_my_stuff/issue.rb', line 454

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:



1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
# File 'lib/plan_my_stuff/issue.rb', line 1111

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:



978
979
980
981
982
983
984
985
986
987
988
989
990
991
# File 'lib/plan_my_stuff/issue.rb', line 978

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:



1042
1043
1044
# File 'lib/plan_my_stuff/issue.rb', line 1042

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



526
527
528
529
530
531
# File 'lib/plan_my_stuff/issue.rb', line 526

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



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

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:



609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
# File 'lib/plan_my_stuff/issue.rb', line 609

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:



490
491
492
# File 'lib/plan_my_stuff/issue.rb', line 490

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)


805
806
807
808
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
# File 'lib/plan_my_stuff/issue.rb', line 805

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


907
908
909
# File 'lib/plan_my_stuff/issue.rb', line 907

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:



1093
1094
1095
# File 'lib/plan_my_stuff/issue.rb', line 1093

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

#blockingArray<PlanMyStuff::Issue>

Lazy-memoized issues that self blocks.

Returns:



1101
1102
1103
# File 'lib/plan_my_stuff/issue.rb', line 1101

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)


471
472
473
474
# File 'lib/plan_my_stuff/issue.rb', line 471

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

#body_commentPlanMyStuff::Comment?

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

Returns:



925
926
927
# File 'lib/plan_my_stuff/issue.rb', line 925

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)


747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
# File 'lib/plan_my_stuff/issue.rb', line 747

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:



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

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:



1149
1150
1151
1152
1153
# File 'lib/plan_my_stuff/issue.rb', line 1149

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)


723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
# File 'lib/plan_my_stuff/issue.rb', line 723

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.



512
513
514
# File 'lib/plan_my_stuff/issue.rb', line 512

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)


1207
1208
1209
# File 'lib/plan_my_stuff/issue.rb', line 1207

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)


1198
1199
1200
# File 'lib/plan_my_stuff/issue.rb', line 1198

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)


899
900
901
# File 'lib/plan_my_stuff/issue.rb', line 899

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:



1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
# File 'lib/plan_my_stuff/issue.rb', line 1178

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:



1021
1022
1023
1024
1025
# File 'lib/plan_my_stuff/issue.rb', line 1021

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)



496
497
498
# File 'lib/plan_my_stuff/issue.rb', line 496

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

#pms_commentsArray<PlanMyStuff::Comment>

Returns only comments created via PMS.

Returns:



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

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

#pms_issue?Boolean

Returns:

  • (Boolean)


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

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:



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 638

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:



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

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:



965
966
967
# File 'lib/plan_my_stuff/issue.rb', line 965

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

#reloadself

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

Returns:

  • (self)


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

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:



586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/plan_my_stuff/issue.rb', line 586

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:



1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
# File 'lib/plan_my_stuff/issue.rb', line 1131

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:



1078
1079
1080
1081
1082
1083
1084
1085
1086
# File 'lib/plan_my_stuff/issue.rb', line 1078

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:



1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
# File 'lib/plan_my_stuff/issue.rb', line 1002

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:



1052
1053
1054
# File 'lib/plan_my_stuff/issue.rb', line 1052

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



542
543
544
545
546
547
# File 'lib/plan_my_stuff/issue.rb', line 542

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)


773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
# File 'lib/plan_my_stuff/issue.rb', line 773

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:



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

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:



561
562
563
564
565
566
567
568
569
570
571
572
573
574
# File 'lib/plan_my_stuff/issue.rb', line 561

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:



679
680
681
682
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
# File 'lib/plan_my_stuff/issue.rb', line 679

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)


841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
# File 'lib/plan_my_stuff/issue.rb', line 841

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:



1063
1064
1065
1066
1067
1068
1069
1070
1071
# File 'lib/plan_my_stuff/issue.rb', line 1063

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:



1031
1032
1033
# File 'lib/plan_my_stuff/issue.rb', line 1031

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)


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

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.



479
480
481
482
483
484
485
486
487
# File 'lib/plan_my_stuff/issue.rb', line 479

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)


952
953
954
955
956
957
958
# File 'lib/plan_my_stuff/issue.rb', line 952

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