plan_my_stuff 0.3.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 (83) 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 +11 -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/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +163 -0
  83. metadata +50 -2
@@ -0,0 +1,661 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Shared base for GitHub Projects V2 wrappers. Holds attribute definitions,
5
+ # generic find/list/update machinery, hydration, and instance helpers.
6
+ # Concrete subclasses (Project, TestingProject) add their own +create!+
7
+ # behavior and optional filtering on +find+/+list+.
8
+ #
9
+ # Not instantiated directly. +find+ and +list+ dispatch via +build_detail+/
10
+ # +build_summary+ to the correct concrete subclass based on the metadata
11
+ # +kind+ field in the project readme.
12
+ class BaseProject < PlanMyStuff::ApplicationRecord
13
+ MAX_AUTO_PAGINATE_ITEMS = 500
14
+ ITEMS_PER_PAGE = 100
15
+
16
+ # @return [String, nil] GitHub node ID
17
+ attribute :id, :string
18
+ # @return [Integer, nil] project number
19
+ attribute :number, :integer
20
+ # @return [Boolean, nil] whether the project is closed
21
+ attribute :closed
22
+ # @return [PlanMyStuff::BaseProjectMetadata] parsed metadata (empty when no PMS metadata present)
23
+ attribute :metadata, default: -> { PlanMyStuff::ProjectMetadata.new }
24
+ # @return [String, nil] full readme as stored on GitHub
25
+ attribute :raw_readme, :string
26
+ # @return [String, nil] project short description (from shortDescription)
27
+ attribute :description, :string
28
+ # @return [Time, nil] GitHub's updatedAt timestamp
29
+ attribute :updated_at
30
+ # @return [String, nil] project title
31
+ attribute :title, :string
32
+ # @return [String, nil] project URL
33
+ attribute :url, :string
34
+ # @return [String, nil] user-visible readme content (without metadata comment)
35
+ attribute :readme, :string
36
+ # @return [Array<Hash>] status options ({id:, name:})
37
+ attribute :statuses, default: -> { [] }
38
+ # @return [Array<Hash>] all field definitions
39
+ attribute :fields, default: -> { [] }
40
+ # @return [Array<PlanMyStuff::ProjectItem>] project items
41
+ attribute :items, default: -> { [] }
42
+ # @return [String, nil] cursor for next page (only in cursor mode)
43
+ attribute :next_cursor, :string
44
+ # @return [Boolean, nil] whether more pages exist (only in cursor mode)
45
+ attribute :has_next_page
46
+
47
+ class << self
48
+ # Generic find - returns whichever concrete project type is at the given
49
+ # number, dispatching on metadata kind. Subclasses may override to apply
50
+ # filtering (e.g. Project raises for testing projects by default).
51
+ #
52
+ # @param number [Integer]
53
+ # @param paginate [Symbol] :auto (default) or :cursor
54
+ # @param cursor [String, nil] pagination cursor for :cursor mode
55
+ #
56
+ # @return [PlanMyStuff::BaseProject]
57
+ #
58
+ def find(number, paginate: :auto, cursor: nil)
59
+ org = PlanMyStuff.configuration.organization
60
+
61
+ case paginate
62
+ when :auto
63
+ find_auto_paginated(org, number)
64
+ when :cursor
65
+ find_with_cursor(org, number, cursor: cursor)
66
+ else
67
+ raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
68
+ end
69
+ end
70
+
71
+ # Generic list - returns all projects in the configured organization,
72
+ # each dispatched to its concrete type (Project or TestingProject).
73
+ # Subclasses may override to apply filtering.
74
+ #
75
+ # @return [Array<PlanMyStuff::BaseProject>]
76
+ #
77
+ def list
78
+ org = PlanMyStuff.configuration.organization
79
+ data = PlanMyStuff.client.graphql(
80
+ PlanMyStuff::GraphQL::Queries::LIST_PROJECTS,
81
+ variables: { org: org },
82
+ )
83
+
84
+ nodes = data.dig(:organization, :projectsV2, :nodes) || []
85
+ nodes.map { |node| build_summary(node) }
86
+ end
87
+
88
+ # Updates an existing project.
89
+ #
90
+ # @param project_number [Integer]
91
+ # @param title [String, nil]
92
+ # @param readme [String, nil] user-visible readme content (metadata preserved)
93
+ # @param description [String, nil] project short description
94
+ # @param metadata [Hash, nil] custom fields to merge into existing metadata
95
+ #
96
+ # @return [PlanMyStuff::BaseProject]
97
+ #
98
+ def update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil)
99
+ org = PlanMyStuff.configuration.organization
100
+ project_id = resolve_project_id(org, project_number)
101
+
102
+ update_input = { projectId: project_id }
103
+ update_input[:title] = title unless title.nil?
104
+ update_input[:shortDescription] = description unless description.nil?
105
+
106
+ if metadata.present? || !readme.nil?
107
+ current = find(project_number)
108
+ parsed = PlanMyStuff::MetadataParser.parse(current.raw_readme)
109
+ existing_metadata = parsed[:metadata]
110
+
111
+ if metadata.present?
112
+ # Seed with fresh metadata when project has no existing PMS metadata
113
+ if existing_metadata[:schema_version].blank?
114
+ existing_metadata = PlanMyStuff::ProjectMetadata.build(user: nil).to_h
115
+ end
116
+
117
+ merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
118
+ existing_metadata = existing_metadata.merge(metadata)
119
+ existing_metadata[:custom_fields] = merged_custom_fields
120
+ PlanMyStuff::CustomFields.new(
121
+ PlanMyStuff.configuration.custom_fields_for(:project),
122
+ merged_custom_fields,
123
+ ).validate!
124
+ end
125
+
126
+ body = readme.nil? ? parsed[:body] : readme
127
+ update_input[:readme] = PlanMyStuff::MetadataParser.serialize(existing_metadata, body)
128
+ end
129
+
130
+ PlanMyStuff.client.graphql(
131
+ PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
132
+ variables: { input: update_input },
133
+ )
134
+
135
+ find(project_number)
136
+ end
137
+
138
+ # Clones an existing GitHub Project into the configured organization.
139
+ # The copy inherits all custom fields and board layout from the source.
140
+ # Returns the newly created project via +find+, dispatched to the correct
141
+ # concrete subclass (Project or TestingProject) based on the cloned readme.
142
+ #
143
+ # @param source_number [Integer] project number of the project to copy from
144
+ # @param title [String] title for the new project
145
+ #
146
+ # @return [PlanMyStuff::BaseProject]
147
+ #
148
+ def clone!(source_number:, title:)
149
+ org = PlanMyStuff.configuration.organization
150
+ org_id = resolve_org_id(org)
151
+ source_project_id = resolve_project_id(org, source_number)
152
+
153
+ data = PlanMyStuff.client.graphql(
154
+ PlanMyStuff::GraphQL::Queries::COPY_PROJECT_V2,
155
+ variables: {
156
+ input: {
157
+ ownerId: org_id,
158
+ projectId: source_project_id,
159
+ title: title,
160
+ includeDraftIssues: false,
161
+ },
162
+ },
163
+ )
164
+
165
+ new_number = data.dig(:copyProjectV2, :projectV2, :number)
166
+ PlanMyStuff::BaseProject.find(new_number)
167
+ end
168
+
169
+ # Resolves a project number, falling back to config.default_project_number.
170
+ #
171
+ # @param project_number [Integer, nil]
172
+ #
173
+ # @return [Integer]
174
+ #
175
+ def resolve_default_project_number(project_number)
176
+ return project_number if project_number.present?
177
+
178
+ PlanMyStuff.configuration.default_project_number ||
179
+ raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
180
+ end
181
+
182
+ private
183
+
184
+ # Builds a summary Project from a list query node.
185
+ # Dispatches to TestingProject when the readme metadata has kind: "testing".
186
+ #
187
+ # @param node [Hash]
188
+ #
189
+ # @return [PlanMyStuff::BaseProject]
190
+ #
191
+ def build_summary(node)
192
+ raw_readme = node[:readme] || ''
193
+ parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
194
+ klass = dispatch_project_class(parsed_meta[:metadata])
195
+ project = klass.new
196
+ project.__send__(:hydrate_summary, node)
197
+ project
198
+ end
199
+
200
+ # Builds a detailed Project from a find query response.
201
+ # Dispatches to TestingProject when the readme metadata has kind: "testing".
202
+ #
203
+ # @param graphql_project [Hash]
204
+ # @param items [Array<Hash>]
205
+ # @param next_cursor [String, nil]
206
+ # @param has_next_page [Boolean, nil]
207
+ #
208
+ # @return [PlanMyStuff::BaseProject]
209
+ #
210
+ def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
211
+ raw_readme = graphql_project[:readme] || ''
212
+ parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
213
+ klass = dispatch_project_class(parsed_meta[:metadata])
214
+ project = klass.new
215
+ project.__send__(
216
+ :hydrate_detail,
217
+ graphql_project,
218
+ items: items,
219
+ next_cursor: next_cursor,
220
+ has_next_page: has_next_page,
221
+ )
222
+ project
223
+ end
224
+
225
+ # Returns the appropriate project class based on the metadata kind field.
226
+ # Always dispatches to a concrete subclass (never BaseProject itself).
227
+ #
228
+ # @param meta_hash [Hash]
229
+ #
230
+ # @return [Class]
231
+ #
232
+ def dispatch_project_class(meta_hash)
233
+ return PlanMyStuff::TestingProject if meta_hash[:kind] == 'testing'
234
+
235
+ PlanMyStuff::Project
236
+ end
237
+
238
+ # @param org [String]
239
+ # @param number [Integer]
240
+ #
241
+ # @return [PlanMyStuff::BaseProject]
242
+ #
243
+ def find_auto_paginated(org, number)
244
+ all_items = []
245
+ cursor = nil
246
+ raw_project = nil
247
+ page = nil
248
+
249
+ loop do
250
+ page = fetch_project_page(org, number, cursor)
251
+ raw_project ||= page[:raw]
252
+ all_items.concat(page[:items])
253
+
254
+ break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
255
+
256
+ cursor = page[:next_cursor]
257
+ end
258
+
259
+ build_detail(
260
+ raw_project,
261
+ items: all_items,
262
+ next_cursor: page[:next_cursor],
263
+ has_next_page: page[:has_next_page],
264
+ )
265
+ end
266
+
267
+ # @param org [String]
268
+ # @param number [Integer]
269
+ # @param cursor [String, nil]
270
+ #
271
+ # @return [PlanMyStuff::BaseProject]
272
+ #
273
+ def find_with_cursor(org, number, cursor:)
274
+ page = fetch_project_page(org, number, cursor)
275
+ build_detail(
276
+ page[:raw],
277
+ items: page[:items],
278
+ next_cursor: page[:next_cursor],
279
+ has_next_page: page[:has_next_page],
280
+ )
281
+ end
282
+
283
+ # Fetches a single page of project data. Returns a lightweight hash
284
+ # for pagination loop consumption (not a Project instance).
285
+ #
286
+ # @param org [String]
287
+ # @param number [Integer]
288
+ # @param cursor [String, nil]
289
+ #
290
+ # @return [Hash] with :raw, :items, :next_cursor, :has_next_page
291
+ #
292
+ def fetch_project_page(org, number, cursor)
293
+ variables = { org: org, number: number }
294
+ variables[:cursor] = cursor if cursor
295
+
296
+ data = PlanMyStuff.client.graphql(
297
+ PlanMyStuff::GraphQL::Queries::FIND_PROJECT,
298
+ variables: variables,
299
+ )
300
+
301
+ raw_project = data.dig(:organization, :projectV2)
302
+ page_info = raw_project.dig(:items, :pageInfo) || {}
303
+ items_data = raw_project.dig(:items, :nodes) || []
304
+
305
+ {
306
+ raw: raw_project,
307
+ items: items_data.map { |item| parse_project_item(item) },
308
+ next_cursor: page_info[:endCursor],
309
+ has_next_page: page_info[:hasNextPage],
310
+ }
311
+ end
312
+
313
+ # @param item [Hash] raw GraphQL project item node
314
+ #
315
+ # @return [Hash]
316
+ #
317
+ def parse_project_item(item)
318
+ content = item[:content] || {}
319
+ field_values = item.dig(:fieldValues, :nodes) || []
320
+ repo_name = content.dig(:repository, :nameWithOwner)
321
+
322
+ {
323
+ id: item[:id],
324
+ type: item[:type],
325
+ content_node_id: content[:id],
326
+ title: content[:title],
327
+ body: content[:body],
328
+ number: content[:number],
329
+ url: content[:url],
330
+ state: content[:state],
331
+ repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
332
+ status: extract_item_status(field_values),
333
+ field_values: parse_field_values(field_values),
334
+ github_response: item,
335
+ }
336
+ end
337
+
338
+ # @param field_values [Array<Hash>]
339
+ #
340
+ # @return [String, nil]
341
+ #
342
+ def extract_item_status(field_values)
343
+ status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
344
+
345
+ status_value&.dig(:name)
346
+ end
347
+
348
+ # @param field_values [Array<Hash>]
349
+ #
350
+ # @return [Hash]
351
+ #
352
+ def parse_field_values(field_values)
353
+ result = {}
354
+
355
+ field_values.each do |fv|
356
+ field_name = fv.dig(:field, :name)
357
+ next unless field_name
358
+
359
+ value = fv[:name] || fv[:text]
360
+ users_node = fv[:users]
361
+ if users_node
362
+ value = (users_node[:nodes] || []).map { |u| u[:login] }
363
+ end
364
+
365
+ result[field_name] = value
366
+ end
367
+
368
+ result
369
+ end
370
+
371
+ # Resolves a project number to its node ID.
372
+ #
373
+ # @param org [String]
374
+ # @param project_number [Integer]
375
+ #
376
+ # @return [String]
377
+ #
378
+ def resolve_project_id(org, project_number)
379
+ data = PlanMyStuff.client.graphql(
380
+ PlanMyStuff::GraphQL::Queries::PROJECT_ID,
381
+ variables: { org: org, number: project_number },
382
+ )
383
+
384
+ data.dig(:organization, :projectV2, :id)
385
+ end
386
+
387
+ # Resolves an organization login to its node ID.
388
+ #
389
+ # @param org [String]
390
+ #
391
+ # @return [String]
392
+ #
393
+ def resolve_org_id(org)
394
+ data = PlanMyStuff.client.graphql(
395
+ PlanMyStuff::GraphQL::Queries::ORG_ID,
396
+ variables: { org: org },
397
+ )
398
+
399
+ data.dig(:organization, :id)
400
+ end
401
+ end
402
+
403
+ # Returns the Status single-select field definition.
404
+ #
405
+ # @return [Hash] with :id and :options keys
406
+ #
407
+ # @raise [PlanMyStuff::APIError] if no Status field exists
408
+ #
409
+ def status_field
410
+ field = fields.find { |f| f[:name] == 'Status' && f[:options] }
411
+
412
+ raise(APIError, "No 'Status' field found on project ##{number}") unless field
413
+
414
+ field
415
+ end
416
+
417
+ # @return [Boolean]
418
+ def pms_project?
419
+ metadata.schema_version.present?
420
+ end
421
+
422
+ # Persists the project. Creates if new, updates if persisted.
423
+ #
424
+ # @raise [PlanMyStuff::StaleObjectError] on update if stale
425
+ #
426
+ # @return [self]
427
+ #
428
+ def save!
429
+ if new_record?
430
+ created = self.class.create!(
431
+ title: title,
432
+ readme: readme || '',
433
+ description: description,
434
+ **create_kwargs_from_metadata,
435
+ )
436
+ hydrate_from_project(created)
437
+ else
438
+ update!(
439
+ title: title,
440
+ readme: readme,
441
+ description: description,
442
+ metadata: metadata_for_update,
443
+ )
444
+ end
445
+
446
+ self
447
+ end
448
+
449
+ # Updates this project on GitHub. Raises StaleObjectError if the remote
450
+ # has been modified since this instance was loaded.
451
+ #
452
+ # @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
453
+ #
454
+ # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
455
+ #
456
+ # @return [self]
457
+ #
458
+ def update!(**attrs)
459
+ raise_if_stale!
460
+
461
+ self.class.update!(
462
+ project_number: number,
463
+ **attrs,
464
+ )
465
+
466
+ reload
467
+ end
468
+
469
+ # Re-fetches this project from GitHub and updates all local attributes.
470
+ #
471
+ # @return [self]
472
+ #
473
+ def reload
474
+ fresh = self.class.find(number)
475
+ hydrate_from_project(fresh)
476
+ self
477
+ end
478
+
479
+ private
480
+
481
+ # Kwargs derived from +metadata+ for forwarding to +self.class.create!+
482
+ # so +save!+ preserves in-memory metadata mutations on new records.
483
+ # Subclasses override to include type-specific metadata attributes.
484
+ #
485
+ # @return [Hash]
486
+ #
487
+ def create_kwargs_from_metadata
488
+ {
489
+ user: metadata.created_by,
490
+ visibility: metadata.visibility,
491
+ custom_fields: metadata.custom_fields.to_h,
492
+ }
493
+ end
494
+
495
+ # Metadata hash forwarded to the class +update!+ so +save!+ preserves
496
+ # in-memory metadata mutations on persisted records. Returns +nil+ when
497
+ # the instance has no PMS metadata to avoid clobbering remote values.
498
+ #
499
+ # @return [Hash, nil]
500
+ #
501
+ def metadata_for_update
502
+ return if metadata.schema_version.blank?
503
+
504
+ metadata.to_h
505
+ end
506
+
507
+ # Populates this instance from a list query node (summary only).
508
+ #
509
+ # @param node [Hash]
510
+ #
511
+ # @return [void]
512
+ #
513
+ def hydrate_summary(node)
514
+ @github_response = node
515
+ self.id = node[:id]
516
+ self.number = node[:number]
517
+ self.title = node[:title]
518
+ self.description = node[:shortDescription]
519
+ self.url = node[:url]
520
+ self.closed = node[:closed]
521
+ self.updated_at = parse_github_time(node[:updatedAt])
522
+ persisted!
523
+ end
524
+
525
+ # Populates this instance from a detailed find query response.
526
+ #
527
+ # @param graphql_project [Hash]
528
+ # @param items [Array<Hash>]
529
+ # @param next_cursor [String, nil]
530
+ # @param has_next_page [Boolean, nil]
531
+ #
532
+ # @return [void]
533
+ #
534
+ def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
535
+ @github_response = graphql_project
536
+ self.id = graphql_project[:id]
537
+ self.number = graphql_project[:number]
538
+ self.title = graphql_project[:title]
539
+ self.description = graphql_project[:shortDescription]
540
+ self.url = graphql_project[:url]
541
+ self.closed = graphql_project[:closed]
542
+ self.updated_at = parse_github_time(graphql_project[:updatedAt])
543
+
544
+ self.raw_readme = graphql_project[:readme] || ''
545
+ parsed = PlanMyStuff::MetadataParser.parse(raw_readme)
546
+ self.metadata = build_project_metadata(parsed[:metadata])
547
+ self.readme = parsed[:body]
548
+
549
+ fields_nodes = graphql_project.dig(:fields, :nodes) || []
550
+ self.statuses = extract_statuses(fields_nodes)
551
+ self.fields = extract_fields(fields_nodes)
552
+ self.items = items.map { |item_hash| item_class.build(item_hash, project: self) }
553
+ self.next_cursor = next_cursor
554
+ self.has_next_page = has_next_page
555
+ persisted!
556
+ end
557
+
558
+ # Copies attributes from another Project instance into self.
559
+ #
560
+ # @param other [PlanMyStuff::BaseProject]
561
+ #
562
+ # @return [void]
563
+ #
564
+ def hydrate_from_project(other)
565
+ @github_response = other.github_response
566
+ self.id = other.id
567
+ self.number = other.number
568
+ self.title = other.title
569
+ self.description = other.description
570
+ self.url = other.url
571
+ self.closed = other.closed
572
+ self.updated_at = other.updated_at
573
+ self.raw_readme = other.raw_readme
574
+ self.readme = other.readme
575
+ self.metadata = other.metadata
576
+ self.statuses = other.statuses
577
+ self.fields = other.fields
578
+ self.items = other.items
579
+ self.next_cursor = other.next_cursor
580
+ self.has_next_page = other.has_next_page
581
+ persisted!
582
+ end
583
+
584
+ # Raises StaleObjectError if the remote project has been modified
585
+ # since this instance was loaded.
586
+ #
587
+ # @raise [PlanMyStuff::StaleObjectError]
588
+ #
589
+ # @return [void]
590
+ #
591
+ def raise_if_stale!
592
+ return if new_record?
593
+ return if updated_at.nil?
594
+
595
+ remote = self.class.find(number)
596
+ remote_time = remote.updated_at
597
+ local_time = updated_at
598
+
599
+ return if remote_time.nil?
600
+ return if local_time && remote_time.to_i == local_time.to_i
601
+
602
+ raise(PlanMyStuff::StaleObjectError.new(
603
+ "Project ##{number} has been modified remotely",
604
+ local_updated_at: local_time,
605
+ remote_updated_at: remote_time,
606
+ ))
607
+ end
608
+
609
+ # Builds the appropriate metadata object for this project instance.
610
+ # Dispatches to TestingProjectMetadata when kind is "testing".
611
+ #
612
+ # @param meta_hash [Hash]
613
+ #
614
+ # @return [PlanMyStuff::BaseProjectMetadata]
615
+ #
616
+ def build_project_metadata(meta_hash)
617
+ if meta_hash[:kind] == 'testing'
618
+ PlanMyStuff::TestingProjectMetadata.from_hash(meta_hash)
619
+ else
620
+ PlanMyStuff::ProjectMetadata.from_hash(meta_hash)
621
+ end
622
+ end
623
+
624
+ # Extracts status options from the "Status" single-select field.
625
+ #
626
+ # @param fields_nodes [Array<Hash>]
627
+ #
628
+ # @return [Array<Hash>]
629
+ #
630
+ def extract_statuses(fields_nodes)
631
+ status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
632
+
633
+ return [] unless status_field
634
+
635
+ (status_field[:options] || []).map do |opt|
636
+ { id: opt[:id], name: opt[:name] }
637
+ end
638
+ end
639
+
640
+ # @param fields_nodes [Array<Hash>]
641
+ #
642
+ # @return [Array<Hash>]
643
+ #
644
+ def extract_fields(fields_nodes)
645
+ fields_nodes.map do |f|
646
+ field = { id: f[:id], name: f[:name] }
647
+ field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
648
+ field
649
+ end
650
+ end
651
+
652
+ # Returns the item class used to build items for this project type.
653
+ # Subclasses override to return a domain-specific item class.
654
+ #
655
+ # @return [Class]
656
+ #
657
+ def item_class
658
+ raise(NotImplementedError, 'Subclass must implement #item_class to return the appropriate item class')
659
+ end
660
+ end
661
+ end