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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/app/controllers/plan_my_stuff/application_controller.rb +4 -1
- data/app/controllers/plan_my_stuff/comments_controller.rb +24 -6
- data/app/controllers/plan_my_stuff/issues_controller.rb +23 -17
- data/app/controllers/plan_my_stuff/labels_controller.rb +5 -5
- data/app/controllers/plan_my_stuff/project_items_controller.rb +6 -0
- data/app/controllers/plan_my_stuff/projects_controller.rb +54 -0
- data/app/views/plan_my_stuff/issues/index.html.erb +4 -4
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +10 -6
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/show.html.erb +4 -4
- data/app/views/plan_my_stuff/projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +2 -0
- data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +6 -4
- data/config/routes.rb +1 -1
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +10 -1
- data/lib/plan_my_stuff/application_record.rb +37 -1
- data/lib/plan_my_stuff/base_metadata.rb +23 -15
- data/lib/plan_my_stuff/client.rb +2 -22
- data/lib/plan_my_stuff/comment.rb +22 -8
- data/lib/plan_my_stuff/comment_metadata.rb +8 -2
- data/lib/plan_my_stuff/configuration.rb +82 -1
- data/lib/plan_my_stuff/custom_fields.rb +70 -0
- data/lib/plan_my_stuff/issue.rb +23 -19
- data/lib/plan_my_stuff/issue_metadata.rb +8 -2
- data/lib/plan_my_stuff/markdown.rb +1 -1
- data/lib/plan_my_stuff/project.rb +280 -19
- data/lib/plan_my_stuff/project_item.rb +19 -11
- data/lib/plan_my_stuff/project_metadata.rb +41 -0
- data/lib/plan_my_stuff/repo.rb +107 -0
- data/lib/plan_my_stuff/test_helpers.rb +10 -2
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff.rb +2 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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(...)
|
data/lib/plan_my_stuff/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
358
|
+
return if updated_at.nil?
|
|
341
359
|
|
|
342
360
|
github_comment = PlanMyStuff.client.rest(:issue_comment, issue.repo, id)
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
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
|
-
#
|
|
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
|
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -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 [
|
|
28
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
455
|
+
return if updated_at.nil?
|
|
452
456
|
|
|
453
457
|
remote = self.class.find(number, repo: repo)
|
|
454
|
-
remote_time = remote.
|
|
455
|
-
local_time =
|
|
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(
|
|
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)
|