plan_my_stuff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +28 -0
  3. data/README.md +284 -0
  4. data/app/controllers/plan_my_stuff/application_controller.rb +76 -0
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +82 -0
  6. data/app/controllers/plan_my_stuff/issues_controller.rb +145 -0
  7. data/app/controllers/plan_my_stuff/labels_controller.rb +30 -0
  8. data/app/controllers/plan_my_stuff/project_items_controller.rb +93 -0
  9. data/app/controllers/plan_my_stuff/projects_controller.rb +17 -0
  10. data/app/views/plan_my_stuff/comments/edit.html.erb +16 -0
  11. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +32 -0
  12. data/app/views/plan_my_stuff/issues/edit.html.erb +12 -0
  13. data/app/views/plan_my_stuff/issues/index.html.erb +37 -0
  14. data/app/views/plan_my_stuff/issues/new.html.erb +7 -0
  15. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +41 -0
  16. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +23 -0
  17. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +32 -0
  18. data/app/views/plan_my_stuff/issues/show.html.erb +58 -0
  19. data/app/views/plan_my_stuff/projects/index.html.erb +13 -0
  20. data/app/views/plan_my_stuff/projects/show.html.erb +101 -0
  21. data/config/routes.rb +25 -0
  22. data/lib/generators/plan_my_stuff/install/install_generator.rb +38 -0
  23. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +106 -0
  24. data/lib/generators/plan_my_stuff/views/views_generator.rb +22 -0
  25. data/lib/plan_my_stuff/application_record.rb +39 -0
  26. data/lib/plan_my_stuff/base_metadata.rb +136 -0
  27. data/lib/plan_my_stuff/client.rb +143 -0
  28. data/lib/plan_my_stuff/comment.rb +360 -0
  29. data/lib/plan_my_stuff/comment_metadata.rb +56 -0
  30. data/lib/plan_my_stuff/configuration.rb +139 -0
  31. data/lib/plan_my_stuff/custom_fields.rb +65 -0
  32. data/lib/plan_my_stuff/engine.rb +11 -0
  33. data/lib/plan_my_stuff/errors.rb +87 -0
  34. data/lib/plan_my_stuff/issue.rb +486 -0
  35. data/lib/plan_my_stuff/issue_metadata.rb +111 -0
  36. data/lib/plan_my_stuff/label.rb +59 -0
  37. data/lib/plan_my_stuff/markdown.rb +83 -0
  38. data/lib/plan_my_stuff/metadata_parser.rb +53 -0
  39. data/lib/plan_my_stuff/project.rb +504 -0
  40. data/lib/plan_my_stuff/project_item.rb +414 -0
  41. data/lib/plan_my_stuff/test_helpers.rb +501 -0
  42. data/lib/plan_my_stuff/user_resolver.rb +61 -0
  43. data/lib/plan_my_stuff/verifier.rb +102 -0
  44. data/lib/plan_my_stuff/version.rb +19 -0
  45. data/lib/plan_my_stuff.rb +69 -0
  46. data/lib/tasks/plan_my_stuff.rake +23 -0
  47. metadata +126 -0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Generators
5
+ class ViewsGenerator < Rails::Generators::Base
6
+ source_root PlanMyStuff::Engine.root.join('app', 'views', 'plan_my_stuff')
7
+
8
+ # @return [void]
9
+ def copy_views
10
+ directory('.', 'app/views/plan_my_stuff')
11
+ end
12
+
13
+ # @return [void]
14
+ def show_done
15
+ say('')
16
+ say('PlanMyStuff views copied to app/views/plan_my_stuff/.', :green)
17
+ say('You can now customize these templates to match your app.', :yellow)
18
+ say('')
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module PlanMyStuff
6
+ # Base class for all PMS domain objects backed by GitHub resources.
7
+ # Provides shared persistence predicates and utility helpers.
8
+ class ApplicationRecord
9
+ include ActiveModel::Model
10
+
11
+ def initialize(**)
12
+ super
13
+ @persisted = false
14
+ end
15
+
16
+ # @return [Boolean]
17
+ def persisted?
18
+ @persisted
19
+ end
20
+
21
+ # @return [Boolean]
22
+ def new_record?
23
+ !@persisted
24
+ end
25
+
26
+ private
27
+
28
+ # Reads a field from an object that may respond to method calls or hash access.
29
+ #
30
+ # @param obj [Object]
31
+ # @param field [Symbol]
32
+ #
33
+ # @return [Object]
34
+ #
35
+ def read_field(obj, field)
36
+ obj.respond_to?(field) ? obj.public_send(field) : obj[field]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module PlanMyStuff
7
+ class BaseMetadata
8
+ SCHEMA_VERSION = '1'
9
+
10
+ # @return [String] schema version for forward compatibility (starts at "1")
11
+ attr_accessor :schema_version
12
+ # @return [String] gem version that created this metadata
13
+ attr_accessor :gem_version
14
+ # @return [String, nil] Rails environment
15
+ attr_accessor :rails_env
16
+ # @return [String, nil] consuming app name from config
17
+ attr_accessor :app_name
18
+ # @return [Time, nil] timestamp of creation
19
+ attr_accessor :created_at
20
+ # @return [Time, nil] timestamp of last update
21
+ attr_accessor :updated_at
22
+ # @return [Integer, nil] consuming app's user ID of the creator
23
+ attr_accessor :created_by
24
+ # @return [String] "public" or "internal"
25
+ attr_accessor :visibility
26
+ # @return [PlanMyStuff::CustomFields, nil] app-defined custom field values
27
+ attr_accessor :custom_fields
28
+
29
+ class << self
30
+ private
31
+
32
+ # Sets common fields on a metadata instance from a parsed hash
33
+ #
34
+ # @param metadata [BaseMetadata]
35
+ # @param hash [Hash]
36
+ #
37
+ # @return [void]
38
+ #
39
+ def apply_common_from_hash(metadata, hash)
40
+ metadata.schema_version = hash[:schema_version]
41
+ metadata.gem_version = hash[:gem_version]
42
+ metadata.rails_env = hash[:rails_env]
43
+ metadata.app_name = hash[:app_name]
44
+ metadata.created_at = parse_time(hash[:created_at])
45
+ metadata.updated_at = parse_time(hash[:updated_at])
46
+ metadata.created_by = hash[:created_by]
47
+ metadata.visibility = hash.fetch(:visibility, 'internal')
48
+ metadata.custom_fields = CustomFields.new(
49
+ PlanMyStuff.configuration.custom_fields,
50
+ hash[:custom_fields] || {},
51
+ )
52
+ end
53
+
54
+ # Sets common fields on a metadata instance for new creation
55
+ #
56
+ # @param metadata [BaseMetadata]
57
+ # @param user [Object, Integer] user object or user_id
58
+ # @param visibility [String] "public" or "internal"
59
+ # @param custom_fields_data [Hash]
60
+ #
61
+ # @return [void]
62
+ #
63
+ def apply_common_build(metadata, user:, visibility: 'internal', custom_fields_data: {})
64
+ config = PlanMyStuff.configuration
65
+ now = Time.now.utc
66
+
67
+ metadata.schema_version = self::SCHEMA_VERSION
68
+ metadata.gem_version = PlanMyStuff::VERSION::STRING
69
+ metadata.rails_env = (defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env.to_s : nil
70
+ metadata.app_name = config.app_name
71
+ metadata.created_at = now
72
+ metadata.updated_at = now
73
+ resolved = UserResolver.resolve(user)
74
+ metadata.created_by = resolved.present? ? UserResolver.user_id(resolved) : nil
75
+ metadata.visibility = visibility
76
+ metadata.custom_fields = CustomFields.new(
77
+ config.custom_fields,
78
+ custom_fields_data,
79
+ )
80
+ end
81
+
82
+ # @return [Time, nil]
83
+ def parse_time(value)
84
+ return if value.nil?
85
+ return value.utc if value.is_a?(Time)
86
+
87
+ Time.parse(value.to_s).utc
88
+ rescue ArgumentError
89
+ nil
90
+ end
91
+ end
92
+
93
+ def initialize
94
+ @visibility = 'internal'
95
+ end
96
+
97
+ # @return [Hash]
98
+ def to_h
99
+ {
100
+ schema_version: schema_version,
101
+ gem_version: gem_version,
102
+ rails_env: rails_env,
103
+ app_name: app_name,
104
+ created_at: format_time(created_at),
105
+ updated_at: format_time(updated_at),
106
+ created_by: created_by,
107
+ visibility: visibility,
108
+ custom_fields: custom_fields.to_h,
109
+ }
110
+ end
111
+
112
+ # @return [Boolean]
113
+ def internal?
114
+ visibility == 'internal'
115
+ end
116
+
117
+ # @return [Boolean]
118
+ def public?
119
+ visibility == 'public'
120
+ end
121
+
122
+ # @return [String]
123
+ def to_json(...)
124
+ to_h.to_json(...)
125
+ end
126
+
127
+ private
128
+
129
+ # @return [String, nil]
130
+ def format_time(time)
131
+ return if time.nil?
132
+
133
+ time.utc.iso8601
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/keys'
4
+ require 'octokit'
5
+
6
+ module PlanMyStuff
7
+ # Infrastructure wrapper around Octokit. Handles auth, error normalization,
8
+ # and repo resolution. Domain modules (Issues, Projects, etc.) use this
9
+ # internally via PlanMyStuff.client.
10
+ class Client
11
+ # @return [Octokit::Client]
12
+ attr_reader :octokit
13
+
14
+ # @return [Client]
15
+ def initialize
16
+ PlanMyStuff.configuration.validate!
17
+
18
+ @octokit = Octokit::Client.new(access_token: PlanMyStuff.configuration.access_token)
19
+ end
20
+
21
+ # Delegates a REST API call to Octokit, normalizing errors.
22
+ #
23
+ # @param method [Symbol] Octokit method name (e.g. :create_issue)
24
+ # @param args [Array] positional arguments
25
+ # @param kwargs [Hash] keyword arguments
26
+ #
27
+ # @return [Object] Octokit response
28
+ #
29
+ def rest(method, *, **kwargs, &)
30
+ if kwargs.empty?
31
+ octokit.public_send(method, *, &)
32
+ else
33
+ octokit.public_send(method, *, **kwargs, &)
34
+ end
35
+ rescue Octokit::TooManyRequests => e
36
+ raise_rate_limit_error(e)
37
+ rescue Octokit::ClientError, Octokit::ServerError => e
38
+ raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
39
+ end
40
+
41
+ # Executes a GraphQL query against GitHub's /graphql endpoint.
42
+ #
43
+ # @param query [String] GraphQL query string
44
+ # @param variables [Hash] GraphQL variables
45
+ #
46
+ # @return [Hash] parsed response data
47
+ #
48
+ def graphql(query, variables: {})
49
+ payload = { query: query }
50
+ payload[:variables] = variables unless variables.empty?
51
+
52
+ response = octokit.post('/graphql', payload.to_json)
53
+ data =
54
+ if response.is_a?(Hash)
55
+ response
56
+ else
57
+ (response.respond_to?(:to_h) ? response.to_h : response)
58
+ end
59
+
60
+ data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
61
+
62
+ check_graphql_errors!(data)
63
+
64
+ data[:data]
65
+ rescue Octokit::TooManyRequests => e
66
+ raise_rate_limit_error(e)
67
+ rescue Octokit::ClientError, Octokit::ServerError => e
68
+ raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
69
+ end
70
+
71
+ # Resolves a repo param to a full "Org/Repo" string.
72
+ #
73
+ # @param repo [Symbol, String, nil] repo key, full string, or nil for default
74
+ #
75
+ # @return [String] full repo path (e.g. "BrandsInsurance/Element")
76
+ #
77
+ # @raise [ArgumentError] if repo cannot be resolved
78
+ #
79
+ def resolve_repo(repo = nil)
80
+ repo ||= PlanMyStuff.configuration.default_repo
81
+
82
+ if repo.nil?
83
+ raise(
84
+ PlanMyStuff::ConfigurationError,
85
+ 'No repo provided and config.default_repo is not set. ' \
86
+ 'Either pass repo: explicitly or set config.default_repo in your initializer.',
87
+ )
88
+ end
89
+
90
+ case repo
91
+ when Symbol
92
+ resolved = PlanMyStuff.configuration.repos[repo]
93
+ raise(ArgumentError, "Unknown repo key: #{repo.inspect}") if resolved.nil?
94
+
95
+ resolved
96
+ when String
97
+ repo
98
+ else
99
+ raise(ArgumentError, "Cannot resolve repo: #{repo.inspect}")
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # @param data [Hash] parsed GraphQL response
106
+ #
107
+ # @raise [GraphQLError] if response contains errors
108
+ #
109
+ # @return [void]
110
+ #
111
+ def check_graphql_errors!(data)
112
+ errors = data[:errors]
113
+ return if errors.blank?
114
+
115
+ messages = errors.filter_map { |e| e[:message] }
116
+ raise(GraphQLError.new(messages.join('; '), errors: errors))
117
+ end
118
+
119
+ # @param exception [Octokit::TooManyRequests]
120
+ #
121
+ # @raise [RateLimitError]
122
+ #
123
+ def raise_rate_limit_error(exception)
124
+ retry_after = parse_retry_after(exception)
125
+ raise(RateLimitError.new(exception.message, retry_after: retry_after))
126
+ end
127
+
128
+ # @param exception [Octokit::TooManyRequests]
129
+ #
130
+ # @return [Time, nil]
131
+ #
132
+ def parse_retry_after(exception)
133
+ headers = exception.response_headers
134
+ return unless headers
135
+
136
+ if headers['retry-after']
137
+ Time.now.utc + headers['retry-after'].to_i
138
+ elsif headers['x-ratelimit-reset']
139
+ Time.at(headers['x-ratelimit-reset'].to_i).utc
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Wraps a GitHub comment with parsed PMS metadata.
5
+ # Class methods provide the public API for CRUD operations.
6
+ #
7
+ # Follows an ActiveRecord-style pattern:
8
+ # - `Comment.new(**attrs)` creates an unpersisted instance
9
+ # - `Comment.create!` / `Comment.list` return persisted instances
10
+ # - `comment.save!` / `comment.update!` / `comment.reload` for persistence
11
+ class Comment < PlanMyStuff::ApplicationRecord
12
+ # @return [Integer] GitHub comment ID
13
+ attr_reader :id
14
+ # @return [String] full body as stored on GitHub
15
+ attr_reader :raw_body
16
+ # @return [PlanMyStuff::CommentMetadata] parsed metadata (empty when no PMS metadata present)
17
+ attr_reader :metadata
18
+
19
+ # @return [String] comment body without the metadata HTML comment
20
+ attr_accessor :body
21
+ # @return [PlanMyStuff::Issue] parent issue
22
+ attr_accessor :issue
23
+ # @param value [Symbol, String, nil]
24
+ attr_writer :visibility
25
+
26
+ class << self
27
+ # Creates a comment on a GitHub issue with PMS metadata and a visible header.
28
+ #
29
+ # @param issue [PlanMyStuff::Issue] parent issue
30
+ # @param body [String]
31
+ # @param user [Object, Integer] user object or user_id
32
+ # @param visibility [Symbol] :public or :internal
33
+ # @param custom_fields [Hash]
34
+ # @param issue_body [Boolean] whether this comment holds the issue body
35
+ #
36
+ # @return [PlanMyStuff::Comment]
37
+ #
38
+ def create!(
39
+ issue:,
40
+ body:,
41
+ user: nil,
42
+ visibility: :public,
43
+ custom_fields: {},
44
+ skip_responded: false,
45
+ issue_body: false
46
+ )
47
+ resolved_user = UserResolver.resolve(user)
48
+ visibility = resolve_visibility(visibility, resolved_user)
49
+ comment_metadata = CommentMetadata.build(
50
+ user: resolved_user,
51
+ visibility: visibility.to_s,
52
+ custom_fields: custom_fields,
53
+ issue_body: issue_body,
54
+ )
55
+
56
+ header = build_header(resolved_user)
57
+ full_body = "#{header}\n\n#{body}"
58
+ serialized_body = MetadataParser.serialize(comment_metadata.to_h, full_body)
59
+
60
+ result = PlanMyStuff.client.rest(:add_comment, issue.repo, issue.number, serialized_body)
61
+
62
+ mark_issue_responded_if_first_support_comment(issue, resolved_user) unless skip_responded
63
+
64
+ build(result, issue: issue)
65
+ end
66
+
67
+ # Updates an existing GitHub comment body.
68
+ #
69
+ # @param id [Integer] comment ID
70
+ # @param repo [String] repo path
71
+ # @param body [String] new serialized body
72
+ #
73
+ # @return [Object] Octokit response
74
+ #
75
+ def update!(id:, repo:, body:)
76
+ PlanMyStuff.client.rest(:update_comment, repo, id, body)
77
+ end
78
+
79
+ # Finds a single comment by ID, given its parent issue.
80
+ #
81
+ # @param id [Integer] GitHub comment ID
82
+ # @param issue [PlanMyStuff::Issue] parent issue
83
+ #
84
+ # @return [PlanMyStuff::Comment]
85
+ #
86
+ def find(id, issue:)
87
+ github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
88
+ build(github_comment, issue: issue)
89
+ end
90
+
91
+ # Lists comments on a GitHub issue, optionally filtering to PMS-only comments.
92
+ #
93
+ # @param issue [PlanMyStuff::Issue] parent issue
94
+ # @param pms_only [Boolean]
95
+ #
96
+ # @return [Array<PlanMyStuff::Comment>]
97
+ #
98
+ def list(issue:, pms_only: false)
99
+ github_comments = PlanMyStuff.client.rest(:issue_comments, issue.repo, issue.number)
100
+ comments = github_comments.map { |gc| build(gc, issue: issue) }
101
+
102
+ pms_only ? comments.select(&:pms_comment?) : comments
103
+ end
104
+
105
+ private
106
+
107
+ # Hydrates a Comment from a GitHub API response.
108
+ #
109
+ # @param github_comment [Object] Octokit comment response
110
+ # @param issue [PlanMyStuff::Issue] parent issue
111
+ #
112
+ # @return [PlanMyStuff::Comment]
113
+ #
114
+ def build(github_comment, issue:)
115
+ comment = new
116
+ comment.__send__(:hydrate_from_github, github_comment, issue: issue)
117
+ comment
118
+ end
119
+
120
+ # Builds the visible header for a comment.
121
+ #
122
+ # @param user [Object, nil] resolved user object
123
+ #
124
+ # @return [String]
125
+ #
126
+ def build_header(user)
127
+ display_name =
128
+ if user.present?
129
+ UserResolver.display_name(user)
130
+ else
131
+ 'Unknown'
132
+ end
133
+
134
+ timestamp = Time.now.utc.strftime('%m/%d/%Y %H:%M')
135
+ "### #{display_name} at #{timestamp}:"
136
+ end
137
+
138
+ # Coerces visibility to :public unless the user is support.
139
+ #
140
+ # @param visibility [Symbol] requested visibility
141
+ # @param user [Object, nil] resolved user object
142
+ #
143
+ # @return [Symbol] :public or :internal
144
+ #
145
+ def resolve_visibility(visibility, user)
146
+ return :public unless visibility.to_sym == :internal
147
+
148
+ return :public if user.blank?
149
+
150
+ return :public unless UserResolver.support?(user)
151
+
152
+ :internal
153
+ end
154
+
155
+ # Sets responded_at on the issue metadata if this is the first support
156
+ # comment and the issue hasn't been responded to yet.
157
+ #
158
+ # @param issue [PlanMyStuff::Issue] parent issue
159
+ # @param user [Object, nil] resolved user object
160
+ #
161
+ # @return [void]
162
+ #
163
+ def mark_issue_responded_if_first_support_comment(issue, user)
164
+ return if user.nil?
165
+
166
+ return unless UserResolver.support?(user)
167
+ return unless issue.pms_issue?
168
+
169
+ return if issue.metadata.responded?
170
+
171
+ Issue.update!(
172
+ number: issue.number,
173
+ repo: issue.repo,
174
+ metadata: { responded_at: Time.now.utc.iso8601 },
175
+ )
176
+ end
177
+ end
178
+
179
+ def initialize(**attrs)
180
+ @id = attrs.delete(:id)
181
+ @raw_body = nil
182
+ @metadata = CommentMetadata.new
183
+ super
184
+ end
185
+
186
+ # Persists the comment. Creates if new, updates if persisted.
187
+ #
188
+ # @raise [PlanMyStuff::StaleObjectError] on update if stale
189
+ #
190
+ # @return [self]
191
+ #
192
+ def save!
193
+ if new_record?
194
+ created = self.class.create!(
195
+ issue: issue,
196
+ body: body,
197
+ visibility: visibility || :public,
198
+ )
199
+ hydrate_from_comment(created)
200
+ else
201
+ update!(body: body)
202
+ end
203
+
204
+ self
205
+ end
206
+
207
+ # Updates this comment on GitHub. Raises StaleObjectError if the remote
208
+ # has been modified since this instance was loaded.
209
+ #
210
+ # @param attrs [Hash] attributes to update (body:, visibility:)
211
+ #
212
+ # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
213
+ #
214
+ # @return [self]
215
+ #
216
+ def update!(**attrs)
217
+ raise_if_stale!
218
+
219
+ new_body = attrs[:body] || body
220
+ meta_hash = metadata.to_h
221
+
222
+ if attrs.key?(:visibility)
223
+ new_visibility = attrs[:visibility].to_s
224
+ meta_hash[:visibility] = new_visibility
225
+ meta_hash[:updated_at] = Time.now.utc.iso8601
226
+ end
227
+
228
+ serialized = MetadataParser.serialize(meta_hash, new_body)
229
+ self.class.update!(id: id, repo: issue.repo, body: serialized)
230
+
231
+ reload
232
+ end
233
+
234
+ # Re-fetches this comment from GitHub and updates all local attributes.
235
+ #
236
+ # @return [self]
237
+ #
238
+ def reload
239
+ github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
240
+ hydrate_from_github(github_comment, issue: issue)
241
+ self
242
+ end
243
+
244
+ # @return [Boolean]
245
+ def pms_comment?
246
+ metadata.schema_version.present?
247
+ end
248
+
249
+ # Returns the comment visibility as a symbol.
250
+ # Uses the locally set value if present, otherwise falls back to metadata.
251
+ #
252
+ # @return [Symbol, nil] :public or :internal
253
+ #
254
+ def visibility
255
+ @visibility || metadata.visibility&.to_sym
256
+ end
257
+
258
+ # Checks if the comment is visible to the given user.
259
+ # Public PMS comments: visible to everyone the parent issue is visible to.
260
+ # Internal PMS comments: visible only to support users.
261
+ # Non-PMS comments: visible only to support users.
262
+ #
263
+ # @param user [Object, Integer] user object or user_id
264
+ #
265
+ # @return [Boolean]
266
+ #
267
+ def visible_to?(user)
268
+ resolved = PMS::UserResolver.resolve(user)
269
+
270
+ if pms_comment?
271
+ issue.visible_to?(resolved) && (visibility != :internal || PMS::UserResolver.support?(resolved))
272
+ else
273
+ PMS::UserResolver.support?(resolved)
274
+ end
275
+ end
276
+
277
+ # Extracts the `### Name at timestamp:` header line from the comment body.
278
+ #
279
+ # @return [String, nil]
280
+ #
281
+ def header
282
+ match = (body || '').match(/\A(###\s.+?:\s*)\n/)
283
+ match&.captures&.first&.strip
284
+ end
285
+
286
+ # Returns the comment body with the header stripped.
287
+ #
288
+ # @return [String]
289
+ #
290
+ def body_without_header
291
+ (body || '').sub(/\A###\s.+?:\s*\n\n/, '')
292
+ end
293
+
294
+ private
295
+
296
+ # Populates this instance from a GitHub API response.
297
+ #
298
+ # @param github_comment [Object] Octokit comment response
299
+ # @param issue [PlanMyStuff::Issue] parent issue
300
+ #
301
+ # @return [void]
302
+ #
303
+ def hydrate_from_github(github_comment, issue:)
304
+ @id = read_field(github_comment, :id)
305
+ @raw_body = read_field(github_comment, :body)
306
+ @issue = issue
307
+
308
+ parsed = MetadataParser.parse(@raw_body)
309
+ @metadata = CommentMetadata.from_hash(parsed[:metadata])
310
+ @body = parsed[:body]
311
+ @visibility = @metadata.visibility&.to_sym
312
+ @persisted = true
313
+ end
314
+
315
+ # Copies attributes from another Comment instance into self.
316
+ #
317
+ # @param other [PlanMyStuff::Comment]
318
+ #
319
+ # @return [void]
320
+ #
321
+ def hydrate_from_comment(other)
322
+ @id = other.id
323
+ @body = other.body
324
+ @raw_body = other.raw_body
325
+ @issue = other.issue
326
+ @metadata = other.metadata
327
+ @visibility = other.visibility
328
+ @persisted = true
329
+ end
330
+
331
+ # Raises StaleObjectError if the remote comment has been modified
332
+ # since this instance was loaded.
333
+ #
334
+ # @raise [PlanMyStuff::StaleObjectError]
335
+ #
336
+ # @return [void]
337
+ #
338
+ def raise_if_stale!
339
+ return if new_record?
340
+ return if metadata.updated_at.nil?
341
+
342
+ github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
343
+ parsed = MetadataParser.parse(
344
+ github_comment.respond_to?(:body) ? github_comment.body : github_comment[:body],
345
+ )
346
+ remote_metadata = CommentMetadata.from_hash(parsed[:metadata])
347
+ remote_time = remote_metadata.updated_at
348
+ local_time = metadata.updated_at
349
+
350
+ return if remote_time.nil?
351
+ return if local_time && remote_time.to_i == local_time.to_i
352
+
353
+ raise(StaleObjectError.new(
354
+ "Comment ##{id} has been modified remotely",
355
+ local_updated_at: local_time,
356
+ remote_updated_at: remote_time,
357
+ ))
358
+ end
359
+ end
360
+ end