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 collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

#new_record?, #persisted?

Constructor Details

#initialize(**attrs) ⇒ Issue

Returns a new instance of Issue.



280
281
282
283
284
285
286
# File 'lib/plan_my_stuff/issue.rb', line 280

def initialize(**attrs)
  @number = attrs.delete(:number)
  @raw_body = nil
  @metadata = IssueMetadata.new
  super
  @labels ||= []
end

Instance Attribute Details

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


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

def body
  return @body if new_record?

  return @body unless pms_issue?

  bc = body_comment
  return bc.body_without_header if bc.present?

  @body
end

#labelsArray<String>

Returns label names.

Returns:

  • (Array<String>)

    label names



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

def labels
  @labels
end

#metadataPlanMyStuff::IssueMetadata (readonly)

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

Returns:



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

def 
  @metadata
end

#numberInteger (readonly)

Returns GitHub issue number.

Returns:

  • (Integer)

    GitHub issue number



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

def number
  @number
end

#raw_bodyString (readonly)

Returns full body as stored on GitHub.

Returns:

  • (String)

    full body as stored on GitHub



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

def raw_body
  @raw_body
end

#repoString

Returns resolved repo path (e.g. “Org/Repo”).

Returns:

  • (String)

    resolved repo path (e.g. “Org/Repo”)



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

def repo
  @repo
end

#stateString

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

Returns:

  • (String)

    issue state (“open” or “closed”)



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

def state
  @state
end

#titleString

Returns issue title.

Returns:

  • (String)

    issue title



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

def title
  @title
end

Class Method Details

.add_viewers(number:, user_ids:, repo: nil) ⇒ Object

Adds user IDs to the visibility allowlist of an issue’s metadata.

Parameters:

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

    defaults to config.default_repo

  • user_ids (Array<Integer>)

Returns:

  • (Object)

    Octokit response



185
186
187
188
189
# File 'lib/plan_my_stuff/issue.rb', line 185

def add_viewers(number:, user_ids:, repo: nil)
  modify_allowlist(number: number, repo: repo) do |allowlist|
    allowlist | Array.wrap(user_ids)
  end
end

.create!(title:, body:, repo: nil, labels: [], user: nil, metadata: {}, add_to_project: nil, 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_allowlist (Array<Integer>) (defaults to: [])

    user IDs for internal comment access

Returns:

Raises:



44
45
46
47
48
49
50
51
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
# File 'lib/plan_my_stuff/issue.rb', line 44

def create!(
  title:,
  body:,
  repo: nil,
  labels: [],
  user: nil,
  metadata: {},
  add_to_project: nil,
  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,
    custom_fields: ,
  )
  .visibility_allowlist = Array.wrap(visibility_allowlist)

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

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

  result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)

  issue = build(result, 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
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:



139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/plan_my_stuff/issue.rb', line 139

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

  github_issue = client.rest(:issue, resolved_repo, number)

  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:



162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/plan_my_stuff/issue.rb', line 162

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

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

  github_issues = client.rest(:list_issues, resolved_repo, **options)
  github_issues.filter_map do |gi|
    next if gi.respond_to?(:pull_request) && gi.pull_request

    build(gi, repo: resolved_repo)
  end
end

.remove_viewers(number:, user_ids:, repo: nil) ⇒ Object

Removes user IDs from the visibility allowlist of an issue’s metadata.

Parameters:

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

    defaults to config.default_repo

  • user_ids (Array<Integer>)

Returns:

  • (Object)

    Octokit response



199
200
201
202
203
# File 'lib/plan_my_stuff/issue.rb', line 199

def remove_viewers(number:, user_ids:, repo: nil)
  modify_allowlist(number: number, repo: repo) do |allowlist|
    allowlist - Array.wrap(user_ids)
  end
end

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

Updates an existing GitHub issue.

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 (Hash, nil) (defaults to: nil)

    custom fields to merge into existing metadata

  • labels (Array<String>, nil) (defaults to: nil)
  • state (Symbol, nil) (defaults to: nil)

    :open or :closed

Returns:

  • (Object)


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
# File 'lib/plan_my_stuff/issue.rb', line 103

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?

  if 
    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

    [:updated_at] = Time.now.utc.iso8601
    options[:body] = MetadataParser.serialize(, '')
  end

  update_body_comment(number, resolved_repo, body) if body

  client.rest(:update_issue, resolved_repo, number, **options) if options.any?
end

Instance Method Details

#body_commentPlanMyStuff::Comment?

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

Returns:



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

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

#commentsArray<PlanMyStuff::Comment>

Lazy-loads and memoizes comments from the GitHub API.

Returns:



345
346
347
# File 'lib/plan_my_stuff/issue.rb', line 345

def comments
  @comments ||= load_comments
end

#pms_commentsArray<PlanMyStuff::Comment>

Returns only comments created via PMS.

Returns:



355
356
357
# File 'lib/plan_my_stuff/issue.rb', line 355

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

#pms_issue?Boolean

Returns:

  • (Boolean)


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

def pms_issue?
  .schema_version.present?
end

#reloadself

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

Returns:

  • (self)


335
336
337
338
339
# File 'lib/plan_my_stuff/issue.rb', line 335

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

#save!self

Persists the issue. Creates if new, updates if persisted.

Returns:

  • (self)

Raises:



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/plan_my_stuff/issue.rb', line 294

def save!
  if new_record?
    created = self.class.create!(
      title: title,
      body: body,
      repo: repo,
      labels: labels || [],
    )
    hydrate_from_issue(created)
  else
    update!(body: body, state: state, labels: labels)
  end

  self
end

#update!(**attrs) ⇒ self

Updates this issue on GitHub. Raises StaleObjectError if the remote has been modified since this instance was loaded.

Parameters:

  • attrs (Hash)

    attributes to update (title:, body:, state:, labels:, metadata:)

Returns:

  • (self)

Raises:



319
320
321
322
323
324
325
326
327
328
329
# File 'lib/plan_my_stuff/issue.rb', line 319

def update!(**attrs)
  raise_if_stale!

  self.class.update!(
    number: number,
    repo: repo,
    **attrs,
  )

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


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

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