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
@@ -1,508 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- # Wraps a GitHub Projects V2 project with its statuses, items, and fields.
5
- # Class methods provide the public API for CRUD and query operations.
4
+ # Wraps a regular (non-testing) GitHub Projects V2 project.
5
+ # Inherits all shared find/list/update/hydrate behavior from BaseProject.
6
+ # Adds +create!+ for regular project creation, and overrides +find+/+list+
7
+ # to exclude testing projects. For testing projects use +TestingProject+;
8
+ # for either type use +BaseProject.find+/+list+.
6
9
  #
7
10
  # Follows an ActiveRecord-style pattern:
8
11
  # - `Project.find` / `Project.list` return persisted instances
9
12
  # - `ProjectItem.add_item` / `ProjectItem.add_draft_item` for adding items
10
13
  # - `ProjectItem.move_item` / `ProjectItem.assign` for item mutations
11
- class Project < PlanMyStuff::ApplicationRecord
12
- MAX_AUTO_PAGINATE_ITEMS = 500
13
- ITEMS_PER_PAGE = 100
14
-
15
- # @return [String] GitHub node ID
16
- attr_reader :id
17
- # @return [Integer] project number
18
- attr_reader :number
19
- # @return [Boolean] whether the project is closed
20
- attr_reader :closed
21
-
22
- # @return [String] project title
23
- attr_accessor :title
24
- # @return [String] project URL
25
- attr_accessor :url
26
- # @return [Array<Hash>] status options ({id:, name:})
27
- attr_accessor :statuses
28
- # @return [Array<Hash>] all field definitions
29
- attr_accessor :fields
30
- # @return [Array<PlanMyStuff::ProjectItem>] project items
31
- attr_accessor :items
32
- # @return [String, nil] cursor for next page (only in cursor mode)
33
- attr_accessor :next_cursor
34
- # @return [Boolean, nil] whether more pages exist (only in cursor mode)
35
- attr_accessor :has_next_page
36
-
14
+ class Project < PlanMyStuff::BaseProject
37
15
  class << self
38
- # Creates a new project in the configured organization.
16
+ # Creates a new regular project in the configured organization with PMS metadata.
39
17
  #
40
18
  # @param title [String]
19
+ # @param user [Object, Integer, nil] user object or user_id
20
+ # @param visibility [String] "public" or "internal"
21
+ # @param custom_fields [Hash] app-defined field values
22
+ # @param readme [String] user-visible readme content
23
+ # @param description [String, nil] project short description
41
24
  #
42
- # @return [Object]
43
- #
44
- def create(title:)
45
- raise(NotImplementedError, "#{name}.create is not yet implemented")
46
- end
47
-
48
- # Updates an existing project.
49
- #
50
- # @param project_number [Integer]
51
- # @param title [String, nil]
52
- #
53
- # @return [Object]
54
- #
55
- def update(project_number:, title: nil)
56
- raise(NotImplementedError, "#{name}.update is not yet implemented")
57
- end
58
-
59
- # Lists all projects in the configured organization.
60
- #
61
- # @return [Array<PlanMyStuff::Project>]
25
+ # @return [PlanMyStuff::Project]
62
26
  #
63
- def list
27
+ def create!(title:, user: nil, visibility: 'internal', custom_fields: {}, readme: '', description: nil)
64
28
  org = PlanMyStuff.configuration.organization
65
- data = PlanMyStuff.client.graphql(list_query, variables: { org: org })
66
-
67
- nodes = data.dig(:organization, :projectsV2, :nodes) || []
68
29
 
69
- nodes.map { |node| build_summary(node) }
30
+ project_metadata = PlanMyStuff::ProjectMetadata.build(
31
+ user: user,
32
+ visibility: visibility,
33
+ custom_fields: custom_fields,
34
+ )
35
+ project_metadata.validate_custom_fields!
36
+
37
+ org_id = resolve_org_id(org)
38
+ data = PlanMyStuff.client.graphql(
39
+ PlanMyStuff::GraphQL::Queries::CREATE_PROJECT,
40
+ variables: { input: { ownerId: org_id, title: title } },
41
+ )
42
+
43
+ new_project = data.dig(:createProjectV2, :projectV2) || {}
44
+ project_id = new_project[:id]
45
+ project_number = new_project[:number]
46
+
47
+ serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
48
+ update_input = { projectId: project_id, readme: serialized_readme }
49
+ update_input[:shortDescription] = description if description.present?
50
+
51
+ PlanMyStuff.client.graphql(
52
+ PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
53
+ variables: { input: update_input },
54
+ )
55
+
56
+ find(project_number)
70
57
  end
71
58
 
72
- # Finds a project by number with its statuses, items, and fields.
59
+ # Finds a regular project by number. Raises if the project is a testing
60
+ # project - call +TestingProject.find+ for those, or +BaseProject.find+
61
+ # if you want either type.
73
62
  #
74
63
  # @param number [Integer]
75
64
  # @param paginate [Symbol] :auto (default) or :cursor
76
- # @param cursor [String, nil] pagination cursor for :cursor mode (from a previous call's next_cursor)
65
+ # @param cursor [String, nil] pagination cursor for :cursor mode
66
+ #
67
+ # @raise [ArgumentError] if the project is a testing project
77
68
  #
78
69
  # @return [PlanMyStuff::Project]
79
70
  #
80
71
  def find(number, paginate: :auto, cursor: nil)
81
- org = PlanMyStuff.configuration.organization
72
+ project = super
82
73
 
83
- case paginate
84
- when :auto
85
- find_auto_paginated(org, number)
86
- when :cursor
87
- find_with_cursor(org, number, cursor: cursor)
88
- else
89
- raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
74
+ unless project.is_a?(self)
75
+ raise(ArgumentError, "Project ##{number} is a testing project; use PlanMyStuff::TestingProject.find")
90
76
  end
77
+
78
+ project
91
79
  end
92
80
 
93
- # Resolves a project number, falling back to config.default_project_number.
94
- #
95
- # @param project_number [Integer, nil]
81
+ # Lists all regular projects in the configured organization.
82
+ # Testing projects are excluded; use +TestingProject.list+ or
83
+ # +BaseProject.list+ to reach them.
96
84
  #
97
- # @return [Integer]
85
+ # @return [Array<PlanMyStuff::Project>]
98
86
  #
99
- def resolve_default_project_number(project_number)
100
- return project_number if project_number.present?
101
-
102
- PlanMyStuff.configuration.default_project_number ||
103
- raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
87
+ def list
88
+ super.select { |p| p.is_a?(self) }
104
89
  end
105
-
106
- private
107
-
108
- # @return [String]
109
- def list_query
110
- <<~GRAPHQL
111
- query($org: String!) {
112
- organization(login: $org) {
113
- projectsV2(first: 100) {
114
- nodes {
115
- id
116
- number
117
- title
118
- url
119
- closed
120
- }
121
- }
122
- }
123
- }
124
- GRAPHQL
125
- end
126
-
127
- # @return [String]
128
- def find_query
129
- <<~GRAPHQL
130
- query($org: String!, $number: Int!, $cursor: String) {
131
- organization(login: $org) {
132
- projectV2(number: $number) {
133
- id
134
- number
135
- title
136
- url
137
- closed
138
- fields(first: 50) {
139
- nodes {
140
- ... on ProjectV2SingleSelectField {
141
- id
142
- name
143
- options {
144
- id
145
- name
146
- }
147
- }
148
- ... on ProjectV2Field {
149
- id
150
- name
151
- }
152
- ... on ProjectV2IterationField {
153
- id
154
- name
155
- }
156
- }
157
- }
158
- items(first: #{ITEMS_PER_PAGE}, after: $cursor) {
159
- pageInfo {
160
- hasNextPage
161
- endCursor
162
- }
163
- nodes {
164
- id
165
- type
166
- content {
167
- ... on Issue {
168
- id
169
- title
170
- number
171
- url
172
- state
173
- repository { nameWithOwner }
174
- }
175
- ... on PullRequest {
176
- id
177
- title
178
- number
179
- url
180
- state
181
- repository { nameWithOwner }
182
- }
183
- ... on DraftIssue {
184
- id
185
- title
186
- }
187
- }
188
- fieldValues(first: 20) {
189
- nodes {
190
- ... on ProjectV2ItemFieldSingleSelectValue {
191
- name
192
- field {
193
- ... on ProjectV2SingleSelectField {
194
- name
195
- }
196
- }
197
- }
198
- ... on ProjectV2ItemFieldTextValue {
199
- text
200
- field {
201
- ... on ProjectV2Field {
202
- name
203
- }
204
- }
205
- }
206
- ... on ProjectV2ItemFieldUserValue {
207
- users(first: 10) {
208
- nodes {
209
- login
210
- }
211
- }
212
- field {
213
- ... on ProjectV2Field {
214
- name
215
- }
216
- }
217
- }
218
- }
219
- }
220
- }
221
- }
222
- }
223
- }
224
- }
225
- GRAPHQL
226
- end
227
-
228
- # @return [String]
229
- def project_id_query
230
- <<~GRAPHQL
231
- query($org: String!, $number: Int!) {
232
- organization(login: $org) {
233
- projectV2(number: $number) {
234
- id
235
- }
236
- }
237
- }
238
- GRAPHQL
239
- end
240
-
241
- # Builds a summary Project from a list query node.
242
- #
243
- # @param node [Hash]
244
- #
245
- # @return [PlanMyStuff::Project]
246
- #
247
- def build_summary(node)
248
- project = new
249
- project.__send__(:hydrate_summary, node)
250
- project
251
- end
252
-
253
- # Builds a detailed Project from a find query response.
254
- #
255
- # @param graphql_project [Hash]
256
- # @param items [Array<Hash>]
257
- # @param next_cursor [String, nil]
258
- # @param has_next_page [Boolean, nil]
259
- #
260
- # @return [PlanMyStuff::Project]
261
- #
262
- def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
263
- project = new
264
- project.__send__(
265
- :hydrate_detail,
266
- graphql_project,
267
- items: items,
268
- next_cursor: next_cursor,
269
- has_next_page: has_next_page,
270
- )
271
- project
272
- end
273
-
274
- # @param org [String]
275
- # @param number [Integer]
276
- #
277
- # @return [PlanMyStuff::Project]
278
- #
279
- def find_auto_paginated(org, number)
280
- all_items = []
281
- cursor = nil
282
- raw_project = nil
283
-
284
- loop do
285
- page = fetch_project_page(org, number, cursor)
286
- raw_project ||= page[:raw]
287
- all_items.concat(page[:items])
288
-
289
- break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
290
-
291
- cursor = page[:next_cursor]
292
- end
293
-
294
- build_detail(raw_project, items: all_items)
295
- end
296
-
297
- # @param org [String]
298
- # @param number [Integer]
299
- # @param cursor [String, nil]
300
- #
301
- # @return [PlanMyStuff::Project]
302
- #
303
- def find_with_cursor(org, number, cursor:)
304
- page = fetch_project_page(org, number, cursor)
305
- build_detail(
306
- page[:raw],
307
- items: page[:items],
308
- next_cursor: page[:next_cursor],
309
- has_next_page: page[:has_next_page],
310
- )
311
- end
312
-
313
- # Fetches a single page of project data. Returns a lightweight hash
314
- # for pagination loop consumption (not a Project instance).
315
- #
316
- # @param org [String]
317
- # @param number [Integer]
318
- # @param cursor [String, nil]
319
- #
320
- # @return [Hash] with :raw, :items, :next_cursor, :has_next_page
321
- #
322
- def fetch_project_page(org, number, cursor)
323
- variables = { org: org, number: number }
324
- variables[:cursor] = cursor if cursor
325
-
326
- data = PlanMyStuff.client.graphql(find_query, variables: variables)
327
-
328
- raw_project = data.dig(:organization, :projectV2)
329
- page_info = raw_project.dig(:items, :pageInfo) || {}
330
- items_data = raw_project.dig(:items, :nodes) || []
331
-
332
- {
333
- raw: raw_project,
334
- items: items_data.map { |item| parse_project_item(item) },
335
- next_cursor: page_info[:endCursor],
336
- has_next_page: page_info[:hasNextPage],
337
- }
338
- end
339
-
340
- # @param item [Hash] raw GraphQL project item node
341
- #
342
- # @return [Hash]
343
- #
344
- def parse_project_item(item)
345
- content = item[:content] || {}
346
- field_values = item.dig(:fieldValues, :nodes) || []
347
- repo_name = content.dig(:repository, :nameWithOwner)
348
-
349
- {
350
- id: item[:id],
351
- type: item[:type],
352
- content_node_id: content[:id],
353
- title: content[:title],
354
- number: content[:number],
355
- url: content[:url],
356
- state: content[:state],
357
- repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
358
- status: extract_item_status(field_values),
359
- field_values: parse_field_values(field_values),
360
- }
361
- end
362
-
363
- # @param field_values [Array<Hash>]
364
- #
365
- # @return [String, nil]
366
- #
367
- def extract_item_status(field_values)
368
- status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
369
-
370
- status_value&.dig(:name)
371
- end
372
-
373
- # @param field_values [Array<Hash>]
374
- #
375
- # @return [Hash]
376
- #
377
- def parse_field_values(field_values)
378
- result = {}
379
-
380
- field_values.each do |fv|
381
- field_name = fv.dig(:field, :name)
382
- next unless field_name
383
-
384
- value = fv[:name] || fv[:text]
385
- users_node = fv[:users]
386
- if users_node
387
- value = (users_node[:nodes] || []).map { |u| u[:login] }
388
- end
389
-
390
- result[field_name] = value
391
- end
392
-
393
- result
394
- end
395
-
396
- # Resolves a project number to its node ID.
397
- #
398
- # @param org [String]
399
- # @param project_number [Integer]
400
- #
401
- # @return [String]
402
- #
403
- def resolve_project_id(org, project_number)
404
- data = PlanMyStuff.client.graphql(
405
- project_id_query,
406
- variables: { org: org, number: project_number },
407
- )
408
-
409
- data.dig(:organization, :projectV2, :id)
410
- end
411
- end
412
-
413
- # @see super
414
- def initialize(**attrs)
415
- @id = attrs.delete(:id)
416
- @number = attrs.delete(:number)
417
- @closed = attrs.delete(:closed)
418
- super
419
- @statuses ||= []
420
- @fields ||= []
421
- @items ||= []
422
- end
423
-
424
- # Returns the Status single-select field definition.
425
- #
426
- # @return [Hash] with :id and :options keys
427
- #
428
- # @raise [PlanMyStuff::APIError] if no Status field exists
429
- #
430
- def status_field
431
- field = fields.find { |f| f[:name] == 'Status' && f[:options] }
432
-
433
- raise(APIError, "No 'Status' field found on project ##{number}") unless field
434
-
435
- field
436
90
  end
437
91
 
438
92
  private
439
93
 
440
- # Populates this instance from a list query node (summary only).
441
- #
442
- # @param node [Hash]
443
- #
444
- # @return [void]
445
- #
446
- def hydrate_summary(node)
447
- @id = node[:id]
448
- @number = node[:number]
449
- @title = node[:title]
450
- @url = node[:url]
451
- @closed = node[:closed]
452
- @persisted = true
453
- end
454
-
455
- # Populates this instance from a detailed find query response.
456
- #
457
- # @param graphql_project [Hash]
458
- # @param items [Array<Hash>]
459
- # @param next_cursor [String, nil]
460
- # @param has_next_page [Boolean, nil]
461
- #
462
- # @return [void]
463
- #
464
- def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
465
- @id = graphql_project[:id]
466
- @number = graphql_project[:number]
467
- @title = graphql_project[:title]
468
- @url = graphql_project[:url]
469
- @closed = graphql_project[:closed]
470
-
471
- fields_nodes = graphql_project.dig(:fields, :nodes) || []
472
- @statuses = extract_statuses(fields_nodes)
473
- @fields = extract_fields(fields_nodes)
474
- @items = items.map { |item_hash| ProjectItem.build(item_hash, project: self) }
475
- @next_cursor = next_cursor
476
- @has_next_page = has_next_page
477
- @persisted = true
478
- end
479
-
480
- # Extracts status options from the "Status" single-select field.
481
- #
482
- # @param fields_nodes [Array<Hash>]
94
+ # Returns the item class used to build items for testing projects.
483
95
  #
484
- # @return [Array<Hash>]
96
+ # @return [Class]
485
97
  #
486
- def extract_statuses(fields_nodes)
487
- status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
488
-
489
- return [] unless status_field
490
-
491
- (status_field[:options] || []).map do |opt|
492
- { id: opt[:id], name: opt[:name] }
493
- end
494
- end
495
-
496
- # @param fields_nodes [Array<Hash>]
497
- #
498
- # @return [Array<Hash>]
499
- #
500
- def extract_fields(fields_nodes)
501
- fields_nodes.map do |f|
502
- field = { id: f[:id], name: f[:name] }
503
- field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
504
- field
505
- end
98
+ def item_class
99
+ PlanMyStuff::ProjectItem
506
100
  end
507
101
  end
508
102
  end