plan_my_stuff 0.2.0 → 0.4.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +65 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/edit.html.erb +7 -0
  31. data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
  32. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  33. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  34. data/app/views/plan_my_stuff/projects/show.html.erb +11 -4
  35. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  36. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  37. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  38. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  39. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  40. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  41. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  42. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  43. data/config/routes.rb +38 -15
  44. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  45. data/lib/plan_my_stuff/application_record.rb +144 -0
  46. data/lib/plan_my_stuff/approval.rb +80 -0
  47. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  48. data/lib/plan_my_stuff/archive.rb +14 -0
  49. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  50. data/lib/plan_my_stuff/base_metadata.rb +0 -11
  51. data/lib/plan_my_stuff/base_project.rb +661 -0
  52. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  53. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  54. data/lib/plan_my_stuff/cache.rb +197 -0
  55. data/lib/plan_my_stuff/client.rb +7 -0
  56. data/lib/plan_my_stuff/comment.rb +174 -54
  57. data/lib/plan_my_stuff/configuration.rb +254 -8
  58. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  59. data/lib/plan_my_stuff/engine.rb +0 -4
  60. data/lib/plan_my_stuff/errors.rb +49 -0
  61. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  62. data/lib/plan_my_stuff/issue.rb +1477 -174
  63. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  64. data/lib/plan_my_stuff/label.rb +82 -11
  65. data/lib/plan_my_stuff/link.rb +144 -0
  66. data/lib/plan_my_stuff/notifications.rb +142 -0
  67. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  68. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  69. data/lib/plan_my_stuff/pipeline.rb +293 -0
  70. data/lib/plan_my_stuff/project.rb +62 -468
  71. data/lib/plan_my_stuff/project_item.rb +3 -417
  72. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  73. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  74. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  75. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  76. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  77. data/lib/plan_my_stuff/reminders.rb +16 -0
  78. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  79. data/lib/plan_my_stuff/testing_project.rb +291 -0
  80. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  81. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  82. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  83. data/lib/plan_my_stuff/version.rb +1 -1
  84. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  85. data/lib/plan_my_stuff.rb +16 -0
  86. data/lib/tasks/plan_my_stuff.rake +163 -0
  87. metadata +54 -2
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # A GitHub Projects V2 project used to track manual verification of features.
5
+ # Identified by kind: "testing" in its readme metadata. Sibling of Project -
6
+ # both inherit shared machinery from BaseProject.
7
+ #
8
+ # BaseProject.find / BaseProject.list dispatch to TestingProject automatically
9
+ # when the metadata kind matches; TestingProject.find raises APIError if the
10
+ # requested project is not a testing project. TestingProject.create!
11
+ # bootstraps testing-specific custom fields after creating the underlying
12
+ # GH project.
13
+ class TestingProject < PlanMyStuff::BaseProject
14
+ # Default GH Project custom field definitions bootstrapped on creation.
15
+ # Fields already present on the project are skipped.
16
+ #
17
+ # "Test Status" is intentionally a separate field from GitHub's built-in
18
+ # "Status" (which BaseProject and Pipeline hardcode). Using a parallel
19
+ # field lets the QA sign-off flow own its own options (Passed/Failed)
20
+ # without colliding with the board's default Status column — a testing
21
+ # project pointed at a pipeline-tracked repo won't overwrite either.
22
+ BOOTSTRAP_FIELDS = [
23
+ { name: 'Test Status', data_type: 'SINGLE_SELECT', options: ['Todo', 'In Progress', 'Passed', 'Failed'] },
24
+ { name: 'Testers', data_type: 'TEXT' },
25
+ { name: 'Watchers', data_type: 'TEXT' },
26
+ { name: 'Pass Mode', data_type: 'SINGLE_SELECT', options: %w[all any] },
27
+ { name: 'Due Date', data_type: 'DATE' },
28
+ { name: 'Deadline Miss Reason', data_type: 'TEXT' },
29
+ { name: 'Result Notes', data_type: 'TEXT' },
30
+ { name: 'Passed By', data_type: 'TEXT' },
31
+ { name: 'Failed By', data_type: 'TEXT' },
32
+ { name: 'Passed At', data_type: 'TEXT' },
33
+ ].freeze
34
+
35
+ # Override metadata default so unhydrated TestingProject instances carry
36
+ # the correct metadata subclass.
37
+ attribute :metadata, default: -> { PlanMyStuff::TestingProjectMetadata.new }
38
+
39
+ class << self
40
+ # Creates a new testing project in the configured organization.
41
+ # Writes TestingProjectMetadata to the project readme and bootstraps
42
+ # any missing custom fields defined in BOOTSTRAP_FIELDS.
43
+ #
44
+ # @param title [String]
45
+ # @param user [Object, Integer, nil]
46
+ # @param visibility [String] "public" or "internal"
47
+ # @param custom_fields [Hash] app-defined metadata field values
48
+ # @param readme [String] user-visible readme content
49
+ # @param description [String, nil] project short description
50
+ # @param subject_urls [Array<String>] PR/issue URLs covered
51
+ # @param due_date [Date, nil] project-level deadline
52
+ # @param deadline_miss_reason [String, nil]
53
+ #
54
+ # @return [PlanMyStuff::TestingProject]
55
+ #
56
+ def create!(
57
+ title:,
58
+ user: nil,
59
+ visibility: 'internal',
60
+ custom_fields: {},
61
+ readme: '',
62
+ description: nil,
63
+ subject_urls: [],
64
+ due_date: nil,
65
+ deadline_miss_reason: nil
66
+ )
67
+ org = PlanMyStuff.configuration.organization
68
+
69
+ project_metadata = PlanMyStuff::TestingProjectMetadata.build(
70
+ user: user,
71
+ visibility: visibility,
72
+ custom_fields: custom_fields,
73
+ subject_urls: subject_urls,
74
+ due_date: due_date,
75
+ deadline_miss_reason: deadline_miss_reason,
76
+ )
77
+ project_metadata.validate_custom_fields!
78
+
79
+ template_number = PlanMyStuff.configuration.testing_template_project_number
80
+
81
+ if template_number.present?
82
+ create_from_template!(
83
+ title: title,
84
+ description: description,
85
+ readme: readme,
86
+ project_metadata: project_metadata,
87
+ template_number: template_number,
88
+ )
89
+ else
90
+ create_with_bootstrap!(
91
+ title: title,
92
+ description: description,
93
+ readme: readme,
94
+ project_metadata: project_metadata,
95
+ org: org,
96
+ )
97
+ end
98
+ end
99
+
100
+ # Finds a testing project by number. Raises if the project exists but is
101
+ # not a testing project.
102
+ #
103
+ # @param number [Integer]
104
+ # @param paginate [Symbol] :auto (default) or :cursor
105
+ # @param cursor [String, nil]
106
+ #
107
+ # @raise [ArgumentError] if the project is not a testing project
108
+ #
109
+ # @return [PlanMyStuff::TestingProject]
110
+ #
111
+ def find(number, paginate: :auto, cursor: nil)
112
+ project = super
113
+
114
+ unless project.is_a?(self)
115
+ raise(ArgumentError, "Project ##{number} is not a testing project; use PlanMyStuff::Project.find")
116
+ end
117
+
118
+ project
119
+ end
120
+
121
+ # Lists all testing projects in the configured organization.
122
+ #
123
+ # @return [Array<PlanMyStuff::TestingProject>]
124
+ #
125
+ def list
126
+ super.select { |p| p.is_a?(self) }
127
+ end
128
+
129
+ private
130
+
131
+ # Creates a TestingProject by cloning the configured template project,
132
+ # then writes PMS metadata to the clone's readme.
133
+ #
134
+ # @param title [String]
135
+ # @param description [String, nil]
136
+ # @param readme [String]
137
+ # @param project_metadata [PlanMyStuff::TestingProjectMetadata]
138
+ # @param template_number [Integer]
139
+ #
140
+ # @return [PlanMyStuff::TestingProject]
141
+ #
142
+ def create_from_template!(title:, description:, readme:, project_metadata:, template_number:)
143
+ project = clone!(source_number: template_number, title: title)
144
+
145
+ serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
146
+ update_input = { projectId: project.id, readme: serialized_readme }
147
+ update_input[:shortDescription] = description if description.present?
148
+
149
+ PlanMyStuff.client.graphql(
150
+ PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
151
+ variables: { input: update_input },
152
+ )
153
+
154
+ find(project.number)
155
+ end
156
+
157
+ # Creates a TestingProject from scratch via createProjectV2, then
158
+ # bootstraps any missing custom fields defined in BOOTSTRAP_FIELDS.
159
+ # Used as a fallback when no template project is configured.
160
+ #
161
+ # @param title [String]
162
+ # @param description [String, nil]
163
+ # @param readme [String]
164
+ # @param project_metadata [PlanMyStuff::TestingProjectMetadata]
165
+ # @param org [String]
166
+ #
167
+ # @return [PlanMyStuff::TestingProject]
168
+ #
169
+ def create_with_bootstrap!(title:, description:, readme:, project_metadata:, org:)
170
+ org_id = resolve_org_id(org)
171
+ data = PlanMyStuff.client.graphql(
172
+ PlanMyStuff::GraphQL::Queries::CREATE_PROJECT,
173
+ variables: { input: { ownerId: org_id, title: title } },
174
+ )
175
+
176
+ new_project = data.dig(:createProjectV2, :projectV2) || {}
177
+ project_id = new_project[:id]
178
+ project_number = new_project[:number]
179
+
180
+ serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
181
+ update_input = { projectId: project_id, readme: serialized_readme }
182
+ update_input[:shortDescription] = description if description.present?
183
+
184
+ PlanMyStuff.client.graphql(
185
+ PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
186
+ variables: { input: update_input },
187
+ )
188
+
189
+ bootstrap_fields!(project_id, project_number)
190
+
191
+ find(project_number)
192
+ end
193
+
194
+ # Creates any missing custom fields on the newly created GH Project.
195
+ #
196
+ # @param project_id [String] GitHub node ID of the project
197
+ # @param project_number [Integer]
198
+ #
199
+ # @return [void]
200
+ #
201
+ def bootstrap_fields!(project_id, project_number)
202
+ current = find(project_number)
203
+ existing_names = current.fields.pluck(:name)
204
+
205
+ BOOTSTRAP_FIELDS.each do |field_def|
206
+ next if existing_names.include?(field_def[:name])
207
+
208
+ create_project_field!(project_id, field_def)
209
+ end
210
+ end
211
+
212
+ # Executes a createProjectV2Field mutation for one field definition.
213
+ #
214
+ # @param project_id [String]
215
+ # @param field_def [Hash] :name, :data_type, and optional :options
216
+ #
217
+ # @return [void]
218
+ #
219
+ def create_project_field!(project_id, field_def)
220
+ input = {
221
+ projectId: project_id,
222
+ dataType: field_def[:data_type],
223
+ name: field_def[:name],
224
+ }
225
+
226
+ if field_def[:data_type] == 'SINGLE_SELECT'
227
+ input[:singleSelectOptions] =
228
+ field_def[:options].map { |opt| { name: opt, color: 'GRAY', description: '' } }
229
+ end
230
+
231
+ PlanMyStuff.client.graphql(
232
+ PlanMyStuff::GraphQL::Queries::CREATE_PROJECT_V2_FIELD,
233
+ variables: { input: input },
234
+ )
235
+ end
236
+
237
+ # @param field_values [Array<Hash>] raw GraphQL field value nodes
238
+ #
239
+ # @return [String, nil]
240
+ #
241
+ def extract_item_status(field_values)
242
+ status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Test Status' }
243
+ status_value&.dig(:name)
244
+ end
245
+ end
246
+
247
+ # Returns the Test Status single-select field definition.
248
+ #
249
+ # @return [Hash] with :id and :options keys
250
+ #
251
+ # @raise [PlanMyStuff::APIError] if no Test Status field exists
252
+ #
253
+ def status_field
254
+ field = fields.find { |f| f[:name] == 'Test Status' && f[:options] }
255
+
256
+ raise(APIError, "No 'Test Status' field found on project ##{number}") unless field
257
+
258
+ field
259
+ end
260
+
261
+ private
262
+
263
+ # @see super
264
+ def create_kwargs_from_metadata
265
+ super.merge(
266
+ subject_urls: metadata.respond_to?(:subject_urls) ? Array.wrap(metadata.subject_urls) : [],
267
+ due_date: metadata.respond_to?(:due_date) ? metadata.due_date : nil,
268
+ deadline_miss_reason: metadata.respond_to?(:deadline_miss_reason) ? metadata.deadline_miss_reason : nil,
269
+ )
270
+ end
271
+
272
+ # Returns the item class used to build items for testing projects.
273
+ #
274
+ # @return [Class]
275
+ #
276
+ def item_class
277
+ PlanMyStuff::TestingProjectItem
278
+ end
279
+
280
+ # @param fields_nodes [Array<Hash>]
281
+ #
282
+ # @return [Array<Hash>]
283
+ #
284
+ def extract_statuses(fields_nodes)
285
+ field = fields_nodes.find { |f| f[:name] == 'Test Status' && f.key?(:options) }
286
+ return [] unless field
287
+
288
+ (field[:options] || []).map { |opt| { id: opt[:id], name: opt[:name] } }
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # A project item belonging to a TestingProject. Extends BaseProjectItem with
5
+ # testing-specific field updaters and pass/fail sign-off logic.
6
+ class TestingProjectItem < PlanMyStuff::BaseProjectItem
7
+ # Updates the Pass Mode single-select field on this testing project item.
8
+ #
9
+ # @param value [String] "all" or "any"
10
+ #
11
+ # @return [Hash] mutation result
12
+ #
13
+ def update_pass_mode!(value)
14
+ self.class.update_single_select_field!(
15
+ project_number: project.number,
16
+ item_id: id,
17
+ field_name: 'Pass Mode',
18
+ value: value,
19
+ )
20
+ end
21
+
22
+ # Updates the due date field on this testing project item.
23
+ #
24
+ # @param date [Date, String]
25
+ #
26
+ # @return [Hash] mutation result
27
+ #
28
+ def update_due_date!(date)
29
+ self.class.update_date_field!(
30
+ project_number: project.number,
31
+ item_id: id,
32
+ field_name: 'Due Date',
33
+ date: date,
34
+ )
35
+ end
36
+
37
+ # Updates the Testers field (comma-joined user IDs) on this item.
38
+ #
39
+ # @param user_ids [Integer, String, Array<Integer, String>]
40
+ #
41
+ # @return [Hash] mutation result
42
+ #
43
+ def update_testers!(user_ids)
44
+ update_field!('Testers', Array.wrap(user_ids).join(', '))
45
+ end
46
+
47
+ # Updates the Watchers field (comma-joined user IDs) on this item.
48
+ #
49
+ # @param user_ids [Integer, String, Array<Integer, String>]
50
+ #
51
+ # @return [Hash] mutation result
52
+ #
53
+ def update_watchers!(user_ids)
54
+ update_field!('Watchers', Array.wrap(user_ids).join(', '))
55
+ end
56
+
57
+ # Updates the Result Notes field on this testing project item.
58
+ #
59
+ # @param notes [String]
60
+ #
61
+ # @return [Hash] mutation result
62
+ #
63
+ def update_result_notes!(notes)
64
+ update_field!('Result Notes', notes)
65
+ end
66
+
67
+ # Records the timestamp when this item was passed as an ISO 8601 string.
68
+ #
69
+ # @param time [Time]
70
+ #
71
+ # @return [Hash] mutation result
72
+ #
73
+ def update_passed_at!(time)
74
+ update_field!('Passed At', time.utc.iso8601)
75
+ end
76
+
77
+ # Updates the Deadline Miss Reason field on this testing project item.
78
+ #
79
+ # @param reason [String]
80
+ #
81
+ # @return [Hash] mutation result
82
+ #
83
+ def update_deadline_miss_reason!(reason)
84
+ update_field!('Deadline Miss Reason', reason)
85
+ end
86
+
87
+ # Appends +user_id+ to the Passed By field and, when the pass_mode
88
+ # condition is satisfied, moves the item to the Passed status.
89
+ #
90
+ # pass_mode "all" - flips to Passed when every tester has signed off.
91
+ # pass_mode "any" - flips to Passed as soon as one tester signs off.
92
+ #
93
+ # No-op when the user has already signed off on this item.
94
+ #
95
+ # @param user [Object] PMS user object of the tester signing off
96
+ #
97
+ # @return [void]
98
+ #
99
+ def mark_passed!(user)
100
+ raise(PMS::ValidationError, 'No user configured for sign-off.') if user.blank?
101
+
102
+ user_id = PMS::UserResolver.user_id(user).to_s
103
+ current = user_ids_from_field('Passed By')
104
+ return if current.include?(user_id)
105
+
106
+ new_passed_by = current + [user_id]
107
+ update_passed_by!(new_passed_by)
108
+ field_values['Passed By'] = new_passed_by.join(', ')
109
+
110
+ testers = user_ids_from_field('Testers')
111
+ pass_mode = field_values['Pass Mode']
112
+
113
+ should_pass =
114
+ case pass_mode
115
+ when 'any' then true
116
+ when 'all' then new_passed_by.sort == testers.sort
117
+ else false
118
+ end
119
+
120
+ return unless should_pass
121
+
122
+ update_passed_at!(Time.now.utc)
123
+ move_to!('Passed')
124
+ end
125
+
126
+ # Records +user_id+ as having failed this item, writes +result_notes+,
127
+ # and moves the item to the Failed status.
128
+ #
129
+ # @param user [Object] PMS user object of the tester failing the item
130
+ # @param result_notes [String] required explanation of the failure
131
+ #
132
+ # @raise [PlanMyStuff::ValidationError] if result_notes is blank
133
+ #
134
+ # @return [void]
135
+ #
136
+ def mark_failed!(user, result_notes:)
137
+ raise(PMS::ValidationError, 'Result notes are required when failing an item.') if result_notes.blank?
138
+ raise(PMS::ValidationError, 'No user configured for sign-off.') if user.blank?
139
+
140
+ update_result_notes!(result_notes)
141
+
142
+ user_id = PMS::UserResolver.user_id(user).to_s
143
+ current = user_ids_from_field('Failed By')
144
+ return if current.include?(user_id)
145
+
146
+ new_failed_by = current + [user_id]
147
+ update_failed_by!(new_failed_by)
148
+ field_values['Failed By'] = new_failed_by.join(', ')
149
+
150
+ move_to!('Failed')
151
+ end
152
+
153
+ private
154
+
155
+ # @param user_ids [Integer, String, Array<Integer, String>]
156
+ #
157
+ # @return [Hash] mutation result
158
+ #
159
+ def update_passed_by!(user_ids)
160
+ update_field!('Passed By', Array.wrap(user_ids).join(', '))
161
+ end
162
+
163
+ # @param user_ids [Integer, String, Array<Integer, String>]
164
+ #
165
+ # @return [Hash] mutation result
166
+ #
167
+ def update_failed_by!(user_ids)
168
+ update_field!('Failed By', Array.wrap(user_ids).join(', '))
169
+ end
170
+
171
+ # Parses a comma-separated field value into an array of trimmed logins.
172
+ #
173
+ # @param field_name [String]
174
+ #
175
+ # @return [Array<String>]
176
+ #
177
+ def user_ids_from_field(field_name)
178
+ val = field_values[field_name]
179
+ return [] if val.blank?
180
+
181
+ val.split(',').map(&:strip).compact_blank
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Metadata for testing projects, stored in the GH Project readme field.
5
+ # Detected at read time by kind: "testing" in the serialized metadata blob.
6
+ class TestingProjectMetadata < BaseProjectMetadata
7
+ # @return [Array<String>] PR/issue URLs this testing project covers
8
+ attr_accessor :subject_urls
9
+ # @return [Date, nil] project-level deadline
10
+ attr_accessor :due_date
11
+ # @return [String, nil] free-text captured when the project deadline slips
12
+ attr_accessor :deadline_miss_reason
13
+
14
+ class << self
15
+ # Builds a TestingProjectMetadata from a parsed hash.
16
+ #
17
+ # @param hash [Hash]
18
+ #
19
+ # @return [PlanMyStuff::TestingProjectMetadata]
20
+ #
21
+ def from_hash(hash)
22
+ metadata = new
23
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:testing))
24
+ metadata.subject_urls = Array.wrap(hash[:subject_urls])
25
+ metadata.due_date = parse_date(hash[:due_date])
26
+ metadata.deadline_miss_reason = hash[:deadline_miss_reason]
27
+ metadata
28
+ end
29
+
30
+ # Builds a new TestingProjectMetadata for project creation.
31
+ #
32
+ # @param user [Object, Integer] user object or user_id
33
+ # @param visibility [String] "public" or "internal"
34
+ # @param custom_fields [Hash] app-defined field values
35
+ # @param subject_urls [Array<String>] PR/issue URLs covered
36
+ # @param due_date [Date, nil] project-level deadline
37
+ # @param deadline_miss_reason [String, nil]
38
+ #
39
+ # @return [PlanMyStuff::TestingProjectMetadata]
40
+ #
41
+ def build(
42
+ user:,
43
+ visibility: 'internal',
44
+ custom_fields: {},
45
+ subject_urls: [],
46
+ due_date: nil,
47
+ deadline_miss_reason: nil
48
+ )
49
+ metadata = new
50
+ apply_common_build(
51
+ metadata,
52
+ user: user,
53
+ visibility: visibility,
54
+ custom_fields_data: custom_fields,
55
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:testing),
56
+ )
57
+ metadata.subject_urls = Array.wrap(subject_urls)
58
+ metadata.due_date = due_date
59
+ metadata.deadline_miss_reason = deadline_miss_reason
60
+ metadata
61
+ end
62
+
63
+ private
64
+
65
+ # @param value [String, Date, nil]
66
+ #
67
+ # @return [Date, nil]
68
+ #
69
+ def parse_date(value)
70
+ return if value.nil?
71
+ return value if value.is_a?(Date)
72
+
73
+ Date.parse(value.to_s)
74
+ rescue ArgumentError, TypeError
75
+ nil
76
+ end
77
+ end
78
+
79
+ def initialize
80
+ super
81
+ @kind = 'testing'
82
+ @subject_urls = []
83
+ end
84
+
85
+ # @return [Hash]
86
+ def to_h
87
+ super.merge(
88
+ subject_urls: subject_urls,
89
+ due_date: due_date&.iso8601,
90
+ deadline_miss_reason: deadline_miss_reason,
91
+ )
92
+ end
93
+ end
94
+ end
@@ -33,21 +33,26 @@ module PlanMyStuff
33
33
 
34
34
  # Extracts the app-side user ID from a user object.
35
35
  #
36
- # @param user [Object] user object (not an integer ID)
36
+ # @param user [Object, nil] user object (not an integer ID)
37
37
  #
38
- # @return [Integer]
38
+ # @return [Integer, nil]
39
39
  #
40
40
  def user_id(user)
41
+ return if user.nil?
42
+
41
43
  user.public_send(PlanMyStuff.configuration.user_id_method)
42
44
  end
43
45
 
44
46
  # Checks whether a user is support staff.
47
+ # Anonymous users (nil) are never support.
45
48
  #
46
- # @param user [Object] user object (not an integer ID)
49
+ # @param user [Object, nil] user object (not an integer ID)
47
50
  #
48
51
  # @return [Boolean]
49
52
  #
50
53
  def support?(user)
54
+ return false if user.nil?
55
+
51
56
  method = PlanMyStuff.configuration.support_method
52
57
 
53
58
  if method.is_a?(Proc)
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 2
6
+ MINOR = 4
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)