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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +569 -38
- data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
- data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
- data/app/controllers/plan_my_stuff/projects_controller.rb +65 -1
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
- data/app/jobs/plan_my_stuff/application_job.rb +9 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
- data/app/views/plan_my_stuff/projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
- 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 +11 -4
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +38 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
- data/lib/plan_my_stuff/application_record.rb +144 -0
- data/lib/plan_my_stuff/approval.rb +80 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +14 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
- data/lib/plan_my_stuff/base_metadata.rb +0 -11
- data/lib/plan_my_stuff/base_project.rb +661 -0
- data/lib/plan_my_stuff/base_project_item.rb +562 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +7 -0
- data/lib/plan_my_stuff/comment.rb +174 -54
- data/lib/plan_my_stuff/configuration.rb +254 -8
- data/lib/plan_my_stuff/custom_fields.rb +31 -17
- data/lib/plan_my_stuff/engine.rb +0 -4
- data/lib/plan_my_stuff/errors.rb +49 -0
- data/lib/plan_my_stuff/graphql/queries.rb +392 -0
- data/lib/plan_my_stuff/issue.rb +1477 -174
- data/lib/plan_my_stuff/issue_metadata.rb +122 -0
- data/lib/plan_my_stuff/label.rb +82 -11
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/notifications.rb +142 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +44 -0
- data/lib/plan_my_stuff/pipeline.rb +293 -0
- data/lib/plan_my_stuff/project.rb +62 -468
- data/lib/plan_my_stuff/project_item.rb +3 -417
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +47 -0
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +16 -0
- data/lib/plan_my_stuff/test_helpers.rb +260 -15
- data/lib/plan_my_stuff/testing_project.rb +291 -0
- data/lib/plan_my_stuff/testing_project_item.rb +184 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +8 -3
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
- data/lib/plan_my_stuff.rb +16 -0
- data/lib/tasks/plan_my_stuff.rake +163 -0
- 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)
|