plan_my_stuff 0.1.1 → 0.3.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/app/controllers/plan_my_stuff/application_controller.rb +4 -1
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +24 -6
  5. data/app/controllers/plan_my_stuff/issues_controller.rb +23 -17
  6. data/app/controllers/plan_my_stuff/labels_controller.rb +5 -5
  7. data/app/controllers/plan_my_stuff/project_items_controller.rb +6 -0
  8. data/app/controllers/plan_my_stuff/projects_controller.rb +54 -0
  9. data/app/views/plan_my_stuff/issues/index.html.erb +4 -4
  10. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +10 -6
  11. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  12. data/app/views/plan_my_stuff/issues/show.html.erb +4 -4
  13. data/app/views/plan_my_stuff/projects/edit.html.erb +7 -0
  14. data/app/views/plan_my_stuff/projects/index.html.erb +2 -0
  15. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  16. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  17. data/app/views/plan_my_stuff/projects/show.html.erb +6 -4
  18. data/config/routes.rb +1 -1
  19. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +10 -1
  20. data/lib/plan_my_stuff/application_record.rb +37 -1
  21. data/lib/plan_my_stuff/base_metadata.rb +23 -15
  22. data/lib/plan_my_stuff/client.rb +2 -22
  23. data/lib/plan_my_stuff/comment.rb +22 -8
  24. data/lib/plan_my_stuff/comment_metadata.rb +8 -2
  25. data/lib/plan_my_stuff/configuration.rb +82 -1
  26. data/lib/plan_my_stuff/custom_fields.rb +70 -0
  27. data/lib/plan_my_stuff/issue.rb +23 -19
  28. data/lib/plan_my_stuff/issue_metadata.rb +8 -2
  29. data/lib/plan_my_stuff/markdown.rb +1 -1
  30. data/lib/plan_my_stuff/project.rb +280 -19
  31. data/lib/plan_my_stuff/project_item.rb +19 -11
  32. data/lib/plan_my_stuff/project_metadata.rb +41 -0
  33. data/lib/plan_my_stuff/repo.rb +107 -0
  34. data/lib/plan_my_stuff/test_helpers.rb +10 -2
  35. data/lib/plan_my_stuff/version.rb +2 -2
  36. data/lib/plan_my_stuff.rb +2 -0
  37. metadata +8 -2
@@ -15,10 +15,6 @@ module PlanMyStuff
15
15
  attr_accessor :rails_env
16
16
  # @return [String, nil] consuming app name from config
17
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
18
  # @return [Integer, nil] consuming app's user ID of the creator
23
19
  attr_accessor :created_by
24
20
  # @return [String] "public" or "internal"
@@ -33,20 +29,19 @@ module PlanMyStuff
33
29
  #
34
30
  # @param metadata [BaseMetadata]
35
31
  # @param hash [Hash]
32
+ # @param custom_fields_schema [Hash{Symbol => Hash}] merged schema for this context
36
33
  #
37
34
  # @return [void]
38
35
  #
39
- def apply_common_from_hash(metadata, hash)
36
+ def apply_common_from_hash(metadata, hash, custom_fields_schema)
40
37
  metadata.schema_version = hash[:schema_version]
41
38
  metadata.gem_version = hash[:gem_version]
42
39
  metadata.rails_env = hash[:rails_env]
43
40
  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
41
  metadata.created_by = hash[:created_by]
47
42
  metadata.visibility = hash.fetch(:visibility, 'internal')
48
43
  metadata.custom_fields = CustomFields.new(
49
- PlanMyStuff.configuration.custom_fields,
44
+ custom_fields_schema,
50
45
  hash[:custom_fields] || {},
51
46
  )
52
47
  end
@@ -57,24 +52,28 @@ module PlanMyStuff
57
52
  # @param user [Object, Integer] user object or user_id
58
53
  # @param visibility [String] "public" or "internal"
59
54
  # @param custom_fields_data [Hash]
55
+ # @param custom_fields_schema [Hash{Symbol => Hash}] merged schema for this context
60
56
  #
61
57
  # @return [void]
62
58
  #
63
- def apply_common_build(metadata, user:, visibility: 'internal', custom_fields_data: {})
59
+ def apply_common_build(
60
+ metadata,
61
+ user:,
62
+ visibility: 'internal',
63
+ custom_fields_data: {},
64
+ custom_fields_schema: {}
65
+ )
64
66
  config = PlanMyStuff.configuration
65
- now = Time.now.utc
66
67
 
67
68
  metadata.schema_version = self::SCHEMA_VERSION
68
69
  metadata.gem_version = PlanMyStuff::VERSION::STRING
69
70
  metadata.rails_env = (defined?(Rails) && Rails.respond_to?(:env)) ? Rails.env.to_s : nil
70
71
  metadata.app_name = config.app_name
71
- metadata.created_at = now
72
- metadata.updated_at = now
73
72
  resolved = UserResolver.resolve(user)
74
73
  metadata.created_by = resolved.present? ? UserResolver.user_id(resolved) : nil
75
74
  metadata.visibility = visibility
76
75
  metadata.custom_fields = CustomFields.new(
77
- config.custom_fields,
76
+ custom_fields_schema,
78
77
  custom_fields_data,
79
78
  )
80
79
  end
@@ -92,6 +91,7 @@ module PlanMyStuff
92
91
 
93
92
  def initialize
94
93
  @visibility = 'internal'
94
+ @custom_fields = {}
95
95
  end
96
96
 
97
97
  # @return [Hash]
@@ -101,8 +101,6 @@ module PlanMyStuff
101
101
  gem_version: gem_version,
102
102
  rails_env: rails_env,
103
103
  app_name: app_name,
104
- created_at: format_time(created_at),
105
- updated_at: format_time(updated_at),
106
104
  created_by: created_by,
107
105
  visibility: visibility,
108
106
  custom_fields: custom_fields.to_h,
@@ -119,6 +117,16 @@ module PlanMyStuff
119
117
  visibility == 'public'
120
118
  end
121
119
 
120
+ # Validates custom fields against the schema.
121
+ #
122
+ # @raise [ActiveModel::ValidationError] if validation fails
123
+ #
124
+ # @return [true]
125
+ #
126
+ def validate_custom_fields!
127
+ custom_fields.validate! if custom_fields.is_a?(PlanMyStuff::CustomFields)
128
+ end
129
+
122
130
  # @return [String]
123
131
  def to_json(...)
124
132
  to_h.to_json(...)
@@ -70,34 +70,14 @@ module PlanMyStuff
70
70
 
71
71
  # Resolves a repo param to a full "Org/Repo" string.
72
72
  #
73
- # @param repo [Symbol, String, nil] repo key, full string, or nil for default
73
+ # @param repo [Symbol, String, PlanMyStuff::Repo, nil] repo key, full string, Repo instance, or nil for default
74
74
  #
75
75
  # @return [String] full repo path (e.g. "BrandsInsurance/Element")
76
76
  #
77
77
  # @raise [ArgumentError] if repo cannot be resolved
78
78
  #
79
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
80
+ PlanMyStuff::Repo.resolve(repo).full_name
101
81
  end
102
82
 
103
83
  private
@@ -20,6 +20,8 @@ module PlanMyStuff
20
20
  attr_accessor :body
21
21
  # @return [PlanMyStuff::Issue] parent issue
22
22
  attr_accessor :issue
23
+ # @return [Time, nil] GitHub's updated_at timestamp
24
+ attr_reader :updated_at
23
25
  # @param value [Symbol, String, nil]
24
26
  attr_writer :visibility
25
27
 
@@ -52,6 +54,7 @@ module PlanMyStuff
52
54
  custom_fields: custom_fields,
53
55
  issue_body: issue_body,
54
56
  )
57
+ comment_metadata.validate_custom_fields!
55
58
 
56
59
  header = build_header(resolved_user)
57
60
  full_body = "#{header}\n\n#{body}"
@@ -217,12 +220,12 @@ module PlanMyStuff
217
220
  raise_if_stale!
218
221
 
219
222
  new_body = attrs[:body] || body
223
+ new_body = preserve_header(new_body) if attrs.key?(:body)
220
224
  meta_hash = metadata.to_h
221
225
 
222
226
  if attrs.key?(:visibility)
223
227
  new_visibility = attrs[:visibility].to_s
224
228
  meta_hash[:visibility] = new_visibility
225
- meta_hash[:updated_at] = Time.now.utc.iso8601
226
229
  end
227
230
 
228
231
  serialized = MetadataParser.serialize(meta_hash, new_body)
@@ -293,6 +296,19 @@ module PlanMyStuff
293
296
 
294
297
  private
295
298
 
299
+ # Prepends the existing header to new_body if the comment currently has one.
300
+ #
301
+ # @param new_body [String]
302
+ #
303
+ # @return [String]
304
+ #
305
+ def preserve_header(new_body)
306
+ existing_header = header
307
+ return new_body if existing_header.blank?
308
+
309
+ "#{existing_header}\n\n#{new_body}"
310
+ end
311
+
296
312
  # Populates this instance from a GitHub API response.
297
313
  #
298
314
  # @param github_comment [Object] Octokit comment response
@@ -303,6 +319,7 @@ module PlanMyStuff
303
319
  def hydrate_from_github(github_comment, issue:)
304
320
  @id = read_field(github_comment, :id)
305
321
  @raw_body = read_field(github_comment, :body)
322
+ @updated_at = parse_github_time(safe_read_field(github_comment, :updated_at))
306
323
  @issue = issue
307
324
 
308
325
  parsed = MetadataParser.parse(@raw_body)
@@ -322,6 +339,7 @@ module PlanMyStuff
322
339
  @id = other.id
323
340
  @body = other.body
324
341
  @raw_body = other.raw_body
342
+ @updated_at = other.updated_at
325
343
  @issue = other.issue
326
344
  @metadata = other.metadata
327
345
  @visibility = other.visibility
@@ -337,15 +355,11 @@ module PlanMyStuff
337
355
  #
338
356
  def raise_if_stale!
339
357
  return if new_record?
340
- return if metadata.updated_at.nil?
358
+ return if updated_at.nil?
341
359
 
342
360
  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
361
+ remote_time = parse_github_time(safe_read_field(github_comment, :updated_at))
362
+ local_time = updated_at
349
363
 
350
364
  return if remote_time.nil?
351
365
  return if local_time && remote_time.to_i == local_time.to_i
@@ -14,7 +14,7 @@ module PlanMyStuff
14
14
  #
15
15
  def from_hash(hash)
16
16
  metadata = new
17
- apply_common_from_hash(metadata, hash)
17
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:comment))
18
18
  metadata.issue_body = hash[:issue_body] || false
19
19
 
20
20
  metadata
@@ -31,7 +31,13 @@ module PlanMyStuff
31
31
  #
32
32
  def build(user:, visibility: 'internal', custom_fields: {}, issue_body: false)
33
33
  metadata = new
34
- apply_common_build(metadata, user: user, visibility: visibility, custom_fields_data: custom_fields)
34
+ apply_common_build(
35
+ metadata,
36
+ user: user,
37
+ visibility: visibility,
38
+ custom_fields_data: custom_fields,
39
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:comment),
40
+ )
35
41
  metadata.issue_body = issue_body
36
42
 
37
43
  metadata
@@ -70,19 +70,73 @@ module PlanMyStuff
70
70
  # @return [String, nil] recipient address for built-in deferred request notifications
71
71
  attr_accessor :deferred_email_to
72
72
 
73
- # App-defined field definitions stored in issue/comment metadata.
73
+ # Shared field definitions stored in issue/comment metadata.
74
74
  # Keys are field names, values are hashes with :type and :required.
75
+ # These fields apply to both issues and comments.
75
76
  #
76
77
  # @return [Hash{Symbol => Hash}]
77
78
  #
78
79
  attr_accessor :custom_fields
79
80
 
81
+ # Issue-only field definitions, deep-merged on top of shared custom_fields.
82
+ # Context-specific config wins on key conflicts.
83
+ #
84
+ # @return [Hash{Symbol => Hash}]
85
+ #
86
+ attr_accessor :issue_custom_fields
87
+
88
+ # Comment-only field definitions, deep-merged on top of shared custom_fields.
89
+ # Context-specific config wins on key conflicts.
90
+ #
91
+ # @return [Hash{Symbol => Hash}]
92
+ #
93
+ attr_accessor :comment_custom_fields
94
+
95
+ # Project-only field definitions, deep-merged on top of shared custom_fields.
96
+ # Context-specific config wins on key conflicts.
97
+ #
98
+ # @return [Hash{Symbol => Hash}]
99
+ #
100
+ attr_accessor :project_custom_fields
101
+
80
102
  # @return [String, nil] URL prefix for building user-facing ticket URLs in the consuming app
81
103
  attr_accessor :issues_url_prefix
82
104
 
83
105
  # @return [String, nil] name of the consuming app, stored in metadata (e.g. "Atlas")
84
106
  attr_accessor :app_name
85
107
 
108
+ # @return [Boolean] whether the release pipeline feature is enabled
109
+ attr_accessor :pipeline_enabled
110
+
111
+ # @return [Integer, nil] GitHub Projects V2 number for the pipeline board (falls back to default_project_number)
112
+ attr_accessor :pipeline_project_number
113
+
114
+ # @return [String, nil] HMAC secret for GitHub webhook signature verification (required when webhooks mounted)
115
+ attr_accessor :webhook_secret
116
+
117
+ # @return [String, nil] shared secret for AWS webhook signature verification (required when AWS webhook mounted)
118
+ attr_accessor :aws_webhook_secret
119
+
120
+ # Canonical status name to display alias map. Allows consuming apps to rename
121
+ # pipeline statuses (e.g. "Submitted" to "Triaged").
122
+ #
123
+ # @return [Hash{String => String}]
124
+ #
125
+ attr_accessor :pipeline_statuses
126
+
127
+ # @return [String] branch name that PRs merge into for "Ready for release" transition
128
+ attr_accessor :main_branch
129
+
130
+ # @return [String] branch name that triggers deployment when a PR merges
131
+ attr_accessor :production_branch
132
+
133
+ # Per-group route mounting toggles. Keys: :webhooks, :issues, :projects.
134
+ # Set a key to false to skip mounting that route group.
135
+ #
136
+ # @return [Hash{Symbol => Boolean}]
137
+ #
138
+ attr_accessor :mount_groups
139
+
86
140
  # Named repo configs. Set via config.repos[:element] = 'BrandsInsurance/Element'.
87
141
  #
88
142
  # @return [Hash{Symbol => String}]
@@ -100,6 +154,14 @@ module PlanMyStuff
100
154
  @markdown_options = {}
101
155
  @job_classes = {}
102
156
  @custom_fields = {}
157
+ @issue_custom_fields = {}
158
+ @comment_custom_fields = {}
159
+ @project_custom_fields = {}
160
+ @pipeline_enabled = true
161
+ @pipeline_statuses = {}
162
+ @main_branch = 'main'
163
+ @production_branch = 'production'
164
+ @mount_groups = { webhooks: true, issues: true, projects: true }
103
165
  end
104
166
 
105
167
  # Sets the authentication block for engine controllers.
@@ -132,6 +194,25 @@ module PlanMyStuff
132
194
  "Missing required PlanMyStuff configuration: #{missing.join(', ')}",
133
195
  )
134
196
  end
197
+
198
+ # Returns the merged custom fields schema for the given context.
199
+ # Context-specific fields deep-merge on top of shared fields.
200
+ #
201
+ # @param context [Symbol] :issue or :comment
202
+ #
203
+ # @return [Hash{Symbol => Hash}]
204
+ #
205
+ def custom_fields_for(context)
206
+ context_fields =
207
+ case context
208
+ when :issue then issue_custom_fields
209
+ when :comment then comment_custom_fields
210
+ when :project then project_custom_fields
211
+ else {}
212
+ end
213
+
214
+ custom_fields.deep_merge(context_fields)
215
+ end
135
216
  end
136
217
 
137
218
  class ConfigurationError < StandardError
@@ -1,10 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_model'
4
+
3
5
  module PlanMyStuff
4
6
  # Dynamic accessor object for app-defined custom fields stored in metadata.
5
7
  # Backed by the config.custom_fields schema, provides both hash-style and
6
8
  # method-style access to field values.
9
+ #
10
+ # Includes ActiveModel::Validations for type checking, required field
11
+ # enforcement, and unknown field detection.
7
12
  class CustomFields
13
+ TYPE_MAP = {
14
+ string: [String],
15
+ integer: [Integer],
16
+ boolean: [TrueClass, FalseClass],
17
+ array: [Array],
18
+ hash: [Hash],
19
+ }.freeze
20
+
21
+ include ActiveModel::Validations
22
+
23
+ validate :validate_custom_fields
24
+
25
+ # @return [Hash{Symbol => Hash}]
26
+ attr_reader :schema
27
+
8
28
  # @param schema [Hash{Symbol => Hash}] field definitions from config.custom_fields
9
29
  # @param data [Hash] parsed field data from metadata JSON
10
30
  #
@@ -61,5 +81,55 @@ module PlanMyStuff
61
81
 
62
82
  super
63
83
  end
84
+
85
+ # @return [void]
86
+ def validate_custom_fields
87
+ validate_unknown_fields
88
+ validate_required_fields
89
+ validate_field_types
90
+ end
91
+
92
+ # @return [void]
93
+ def validate_unknown_fields
94
+ known_keys = @schema.keys
95
+ @data.each_key do |key|
96
+ if known_keys.exclude?(key)
97
+ errors.add(:base, "#{key} is not a recognized custom field")
98
+ end
99
+ end
100
+ end
101
+
102
+ # @return [void]
103
+ def validate_required_fields
104
+ @schema.each do |field_name, field_config|
105
+ if field_config[:required] && !@data.key?(field_name)
106
+ errors.add(:base, "#{field_name} is required")
107
+ end
108
+ end
109
+ end
110
+
111
+ # @return [void]
112
+ def validate_field_types
113
+ @schema.each do |field_name, field_config|
114
+ next unless @data.key?(field_name)
115
+
116
+ value = @data[field_name]
117
+ next if value.nil?
118
+
119
+ expected_type = field_config[:type]
120
+ ruby_types = TYPE_MAP[expected_type]
121
+ next if ruby_types.nil?
122
+ next if ruby_types.any? { |t| value.is_a?(t) }
123
+
124
+ expected_name =
125
+ if expected_type == :boolean
126
+ 'TrueClass/FalseClass'
127
+ else
128
+ ruby_types.first.name
129
+ end
130
+
131
+ errors.add(:base, "#{field_name} must be a #{expected_name}, got #{value.class}")
132
+ end
133
+ end
64
134
  end
65
135
  end
@@ -24,8 +24,10 @@ module PlanMyStuff
24
24
  attr_accessor :state
25
25
  # @return [Array<String>] label names
26
26
  attr_accessor :labels
27
- # @return [String] resolved repo path (e.g. "Org/Repo")
28
- attr_accessor :repo
27
+ # @return [Time, nil] GitHub's updated_at timestamp
28
+ attr_reader :updated_at
29
+ # @return [PlanMyStuff::Repo, nil]
30
+ attr_reader :repo
29
31
 
30
32
  class << self
31
33
  # Creates a GitHub issue with PMS metadata embedded in the body.
@@ -61,6 +63,7 @@ module PlanMyStuff
61
63
  custom_fields: metadata,
62
64
  )
63
65
  issue_metadata.visibility_allowlist = Array.wrap(visibility_allowlist)
66
+ issue_metadata.validate_custom_fields!
64
67
 
65
68
  serialized_body = MetadataParser.serialize(issue_metadata.to_h, '')
66
69
 
@@ -68,8 +71,9 @@ module PlanMyStuff
68
71
  options[:labels] = labels if labels.any?
69
72
 
70
73
  result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
74
+ number = read_field(result, :number)
71
75
 
72
- issue = build(result, repo: resolved_repo)
76
+ issue = find(number, repo: resolved_repo)
73
77
 
74
78
  if add_to_project.present?
75
79
  project_number = resolve_project_number(add_to_project)
@@ -119,8 +123,11 @@ module PlanMyStuff
119
123
  merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
120
124
  existing_metadata = existing_metadata.merge(metadata)
121
125
  existing_metadata[:custom_fields] = merged_custom_fields
126
+ PlanMyStuff::CustomFields.new(
127
+ PlanMyStuff.configuration.custom_fields_for(:issue),
128
+ merged_custom_fields,
129
+ ).validate!
122
130
 
123
- existing_metadata[:updated_at] = Time.now.utc.iso8601
124
131
  options[:body] = MetadataParser.serialize(existing_metadata, '')
125
132
  end
126
133
 
@@ -244,7 +251,6 @@ module PlanMyStuff
244
251
  existing_metadata = parsed[:metadata]
245
252
  allowlist = Array.wrap(existing_metadata[:visibility_allowlist])
246
253
  existing_metadata[:visibility_allowlist] = yield(allowlist)
247
- existing_metadata[:updated_at] = Time.now.utc.iso8601
248
254
 
249
255
  new_body = MetadataParser.serialize(existing_metadata, parsed[:body])
250
256
  client.rest(:update_issue, resolved_repo, number, body: new_body)
@@ -264,16 +270,7 @@ module PlanMyStuff
264
270
  body_comment = issue.body_comment
265
271
  raise(PlanMyStuff::Error, "No body comment found on issue ##{number}") if body_comment.nil?
266
272
 
267
- header = body_comment.header
268
-
269
- updated_body =
270
- if header.present?
271
- "#{header}\n\n#{new_body}"
272
- else
273
- new_body
274
- end
275
-
276
- body_comment.update!(body: updated_body)
273
+ body_comment.update!(body: new_body)
277
274
  end
278
275
  end
279
276
 
@@ -285,6 +282,11 @@ module PlanMyStuff
285
282
  @labels ||= []
286
283
  end
287
284
 
285
+ # @param value [PlanMyStuff::Repo, Symbol, String, nil]
286
+ def repo=(value)
287
+ @repo = value.present? ? PlanMyStuff::Repo.resolve(value) : nil
288
+ end
289
+
288
290
  # Persists the issue. Creates if new, updates if persisted.
289
291
  #
290
292
  # @raise [PlanMyStuff::StaleObjectError] on update if stale
@@ -410,8 +412,9 @@ module PlanMyStuff
410
412
  @title = read_field(github_issue, :title)
411
413
  @state = read_field(github_issue, :state)
412
414
  @raw_body = read_field(github_issue, :body) || ''
415
+ @updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
413
416
  @labels = extract_labels(github_issue)
414
- @repo = repo
417
+ self.repo = repo
415
418
 
416
419
  parsed = MetadataParser.parse(@raw_body)
417
420
  @metadata = IssueMetadata.from_hash(parsed[:metadata])
@@ -432,6 +435,7 @@ module PlanMyStuff
432
435
  @state = other.state
433
436
  @body = other.instance_variable_get(:@body)
434
437
  @raw_body = other.raw_body
438
+ @updated_at = other.updated_at
435
439
  @labels = other.labels
436
440
  @repo = other.repo
437
441
  @metadata = other.metadata
@@ -448,11 +452,11 @@ module PlanMyStuff
448
452
  #
449
453
  def raise_if_stale!
450
454
  return if new_record?
451
- return if metadata.updated_at.nil?
455
+ return if updated_at.nil?
452
456
 
453
457
  remote = self.class.find(number, repo: repo)
454
- remote_time = remote.metadata.updated_at
455
- local_time = metadata.updated_at
458
+ remote_time = remote.updated_at
459
+ local_time = updated_at
456
460
 
457
461
  return if remote_time.nil?
458
462
  return if local_time && remote_time.to_i == local_time.to_i
@@ -22,7 +22,7 @@ module PlanMyStuff
22
22
  #
23
23
  def from_hash(hash)
24
24
  metadata = new
25
- apply_common_from_hash(metadata, hash)
25
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:issue))
26
26
 
27
27
  metadata.responded_at = parse_time(hash[:responded_at])
28
28
  metadata.issues_url = hash[:issues_url]
@@ -42,7 +42,13 @@ module PlanMyStuff
42
42
  #
43
43
  def build(user:, visibility: 'public', custom_fields: {})
44
44
  metadata = new
45
- apply_common_build(metadata, user: user, visibility: visibility, custom_fields_data: custom_fields)
45
+ apply_common_build(
46
+ metadata,
47
+ user: user,
48
+ visibility: visibility,
49
+ custom_fields_data: custom_fields,
50
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:issue),
51
+ )
46
52
 
47
53
  metadata.responded_at = nil
48
54
  metadata.issues_url = build_issues_url(PlanMyStuff.configuration)
@@ -26,7 +26,7 @@ module PlanMyStuff
26
26
  when :redcarpet
27
27
  render_redcarpet(text, merged)
28
28
  when nil
29
- "<code>#{text}</code>"
29
+ "<code>#{ERB::Util.html_escape(text)}</code>"
30
30
  else
31
31
  raise(
32
32
  ArgumentError,