Class: PlanMyStuff::Comment

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

Overview

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

Follows an ActiveRecord-style pattern:

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

  • ‘Comment.create!` / `Comment.list` return persisted instances

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

Instance Attribute Summary collapse

Attributes inherited from ApplicationRecord

#github_response

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

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

Constructor Details

This class inherits a constructor from PlanMyStuff::ApplicationRecord

Instance Attribute Details

#waiting_on_replyBoolean?

Returns transient flag used by the new-comment form to request waiting-on-user state when a support user posts. Not persisted.

Returns:

  • (Boolean, nil)

    transient flag used by the new-comment form to request waiting-on-user state when a support user posts. Not persisted.



29
30
31
# File 'lib/plan_my_stuff/comment.rb', line 29

def waiting_on_reply
  @waiting_on_reply
end

Class Method Details

.create!(issue:, body:, user: nil, visibility: :public, custom_fields: {}, skip_responded: false, issue_body: false, waiting_on_reply: false) ⇒ PlanMyStuff::Comment

Creates a comment on a GitHub issue with PMS metadata and a visible header.

Parameters:

  • issue (PlanMyStuff::Issue)

    parent issue

  • body (String)
  • user (Object, Integer) (defaults to: nil)

    user object or user_id

  • visibility (Symbol) (defaults to: :public)

    :public or :internal

  • custom_fields (Hash) (defaults to: {})
  • issue_body (Boolean) (defaults to: false)

    whether this comment holds the issue body

  • waiting_on_reply (Boolean) (defaults to: false)

    when true and the author is a support user, marks the issue as waiting on an end-user reply. Ignored for non-support authors.

Returns:

Raises:



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

def create!(
  issue:,
  body:,
  user: nil,
  visibility: :public,
  custom_fields: {},
  skip_responded: false,
  issue_body: false,
  waiting_on_reply: false
)
  raise(PlanMyStuff::LockedIssueError, "Issue ##{issue.number} is locked") if issue.locked?

  resolved_user = UserResolver.resolve(user)
  visibility = resolve_visibility(visibility, resolved_user)
   = CommentMetadata.build(
    user: resolved_user,
    visibility: visibility.to_s,
    custom_fields: custom_fields,
    issue_body: issue_body,
  )
  .validate_custom_fields!

  header = build_header(resolved_user)
  full_body = "#{header}\n\n#{body}"
  serialized_body = MetadataParser.serialize(.to_h, full_body)

  client = PlanMyStuff.client
  result = client.rest(:add_comment, issue.repo, issue.number, serialized_body)
  store_etag_to_cache(
    client,
    issue.repo,
    read_field(result, :id),
    result,
    cache_writer: :write_comment,
  )

  mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded

  comment = build(result, issue: issue)
  PlanMyStuff::Notifications.instrument('comment.created', comment, user: resolved_user)
  apply_waiting_state_transitions(issue, resolved_user, waiting_on_reply, comment)
  comment
end

.find(id, issue:) ⇒ PlanMyStuff::Comment

Finds a single comment by ID, given its parent issue.

Parameters:

Returns:



118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/plan_my_stuff/comment.rb', line 118

def find(id, issue:)
  client = PlanMyStuff.client
  github_comment =
    fetch_with_etag_cache(
      client,
      issue.repo,
      id,
      rest_method: :issue_comment,
      cache_reader: :read_comment,
      cache_writer: :write_comment,
    )
  build(github_comment, issue: issue)
end

.list(issue:, pms_only: false) ⇒ Array<PlanMyStuff::Comment>

Lists comments on a GitHub issue, optionally filtering to PMS-only comments.

Parameters:

Returns:



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/plan_my_stuff/comment.rb', line 139

def list(issue:, pms_only: false)
  client = PlanMyStuff.client
  params = { issue_number: issue.number }

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

  github_comments = client.rest(:issue_comments, issue.repo, issue.number, **request_options)

  comments =
    if cached && not_modified?(client)
      cached[:body].map { |gc| build(gc, issue: issue) }
    else
      store_list_etag_to_cache(client, :comment, issue.repo, params, github_comments)
      github_comments.map { |gc| build(gc, issue: issue) }
    end

  pms_only ? comments.select(&:pms_comment?) : comments
end

.update!(id:, repo:, body:) ⇒ Object

Updates an existing GitHub comment body.

Parameters:

  • id (Integer)

    comment ID

  • repo (String)

    repo path

  • body (String)

    new serialized body

Returns:

  • (Object)

    Octokit response



98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/plan_my_stuff/comment.rb', line 98

def update!(id:, repo:, body:)
  client = PlanMyStuff.client
  result = client.rest(:update_comment, repo, id, body)
  store_etag_to_cache(
    client,
    repo,
    id,
    result,
    cache_writer: :write_comment,
  )
  result
end

Instance Method Details

#as_json(options = {}) ⇒ Hash

Serializes the comment to a JSON-safe hash, excluding the back-reference to the parent issue to prevent recursive serialization cycles.

Returns:

  • (Hash)


340
341
342
343
# File 'lib/plan_my_stuff/comment.rb', line 340

def as_json(options = {})
  merged_except = Array.wrap(options[:except]) + ['issue']
  super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
end

#bodyString?

Returns comment body without the metadata HTML comment.

Returns:

  • (String, nil)

    comment body without the metadata HTML comment



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

attribute :body, :string

#body_without_headerString

Returns the comment body with the header stripped.

Returns:

  • (String)


391
392
393
# File 'lib/plan_my_stuff/comment.rb', line 391

def body_without_header
  (body || '').sub(/\A###\s.+?:\s*\n\n/, '')
end

#headerString?

Extracts the ‘### Name at timestamp:` header line from the comment body.

Returns:

  • (String, nil)


382
383
384
385
# File 'lib/plan_my_stuff/comment.rb', line 382

def header
  match = (body || '').match(/\A(###\s.+?:\s*)\n/)
  match&.captures&.first&.strip
end

#html_urlString?

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

Returns:

  • (String, nil)


331
332
333
# File 'lib/plan_my_stuff/comment.rb', line 331

def html_url
  safe_read_field(github_response, :html_url)
end

#idInteger?

Returns GitHub comment ID.

Returns:

  • (Integer, nil)

    GitHub comment ID



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

attribute :id, :big_integer

#issuePlanMyStuff::Issue?

Returns parent issue.

Returns:



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

attribute :issue

#metadataPlanMyStuff::CommentMetadata

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

Returns:



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

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

#pms_comment?Boolean

Returns:

  • (Boolean)


346
347
348
# File 'lib/plan_my_stuff/comment.rb', line 346

def pms_comment?
  .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/comment.rb', line 15

attribute :raw_body, :string

#reloadself

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

Returns:

  • (self)


321
322
323
324
325
# File 'lib/plan_my_stuff/comment.rb', line 321

def reload
  fresh = self.class.find(id, issue: issue)
  hydrate_from_comment(fresh)
  self
end

#save!self

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

Returns:

  • (self)

Raises:



271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/plan_my_stuff/comment.rb', line 271

def save!
  if new_record?
    created = self.class.create!(
      issue: issue,
      body: body,
      visibility: visibility || :public,
    )
    hydrate_from_comment(created)
  else
    update!(body: body)
  end

  self
end

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

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

Parameters:

  • attrs (Hash)

    attributes to update (body:, visibility:)

Returns:

  • (self)

Raises:



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/plan_my_stuff/comment.rb', line 295

def update!(user: nil, **attrs)
  raise_if_stale!

  captured_changes = capture_update_changes(attrs)

  new_body = attrs[:body] || body
  new_body = preserve_header(new_body) if attrs.key?(:body)
  meta_hash = .to_h

  if attrs.key?(:visibility)
    new_visibility = attrs[:visibility].to_s
    meta_hash[:visibility] = new_visibility
  end

  serialized = MetadataParser.serialize(meta_hash, new_body)
  self.class.update!(id: id, repo: issue.repo, body: serialized)

  reload
  PlanMyStuff::Notifications.instrument('comment.updated', self, user: user, changes: captured_changes)
  self
end

#updated_atTime?

Returns GitHub’s updated_at timestamp.

Returns:

  • (Time, nil)

    GitHub’s updated_at timestamp



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

attribute :updated_at

#visibilitySymbol?

Returns the comment visibility as a symbol. Uses the locally set value if present, otherwise falls back to metadata.

Returns:

  • (Symbol, nil)

    :public or :internal



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

attribute :visibility

#visible_to?(user) ⇒ Boolean

Checks if the comment is visible to the given user. Public PMS comments: visible to everyone the parent issue is visible to. Internal PMS comments: visible only to support users. Non-PMS comments: visible only to support users.

Parameters:

  • user (Object, Integer)

    user object or user_id

Returns:

  • (Boolean)


368
369
370
371
372
373
374
375
376
# File 'lib/plan_my_stuff/comment.rb', line 368

def visible_to?(user)
  resolved = PMS::UserResolver.resolve(user)

  if pms_comment?
    issue.visible_to?(resolved) && (visibility != :internal || PMS::UserResolver.support?(resolved))
  else
    PMS::UserResolver.support?(resolved)
  end
end