plan_my_stuff 0.1.0 → 1.0.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -0,0 +1,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Shared base for GitHub Projects V2 wrappers. Holds attribute definitions, generic find/list/update machinery,
5
+ # hydration, and instance helpers. Concrete subclasses (Project, TestingProject) add their own +create!+ behavior
6
+ # and optional filtering on +find+/+list+.
7
+ #
8
+ # Not instantiated directly. +find+ and +list+ dispatch via +build_detail+/+build_summary+ to the correct concrete
9
+ # subclass based on the metadata +kind+ field in the project readme.
10
+ class BaseProject < PlanMyStuff::ApplicationRecord
11
+ MAX_AUTO_PAGINATE_ITEMS = 500
12
+ ITEMS_PER_PAGE = 100
13
+
14
+ # @return [String, nil] GitHub node ID
15
+ attribute :id, :string
16
+ # @return [Integer, nil] project number
17
+ attribute :number, :integer
18
+ # @return [Boolean, nil] whether the project is closed
19
+ attribute :closed
20
+ # @return [PlanMyStuff::BaseProjectMetadata] parsed metadata (empty when no PMS metadata present)
21
+ attribute :metadata, default: -> { PlanMyStuff::ProjectMetadata.new }
22
+ # @return [String, nil] full readme as stored on GitHub
23
+ attribute :raw_readme, :string
24
+ # @return [String, nil] project short description (from shortDescription)
25
+ attribute :description, :string
26
+ # @return [Time, nil] GitHub's updatedAt timestamp
27
+ attribute :updated_at
28
+ # @return [String, nil] project title
29
+ attribute :title, :string
30
+ # @return [String, nil] project URL
31
+ attribute :url, :string
32
+ # @return [String, nil] user-visible readme content (without metadata comment)
33
+ attribute :readme, :string
34
+ # @return [Array<Hash>] status options ({id:, name:})
35
+ attribute :statuses, default: -> { [] }
36
+ # @return [Array<Hash>] all field definitions
37
+ attribute :fields, default: -> { [] }
38
+ # @return [Array<PlanMyStuff::ProjectItem>] project items
39
+ attribute :items, default: -> { [] }
40
+ # @return [String, nil] cursor for next page (only in cursor mode)
41
+ attribute :next_cursor, :string
42
+ # @return [Boolean, nil] whether more pages exist (only in cursor mode)
43
+ attribute :has_next_page
44
+
45
+ class << self
46
+ include PlanMyStuff::BaseProjectExtractions::GraphqlHydration
47
+
48
+ # Generic find - returns whichever concrete project type is at the given number, dispatching on metadata kind.
49
+ # Subclasses may override to apply filtering (e.g. Project raises for testing projects by default).
50
+ #
51
+ # @raise [ArgumentError] if paginate mode is invalid
52
+ #
53
+ # @param number [Integer]
54
+ # @param paginate [Symbol] :auto (default) or :cursor
55
+ # @param cursor [String, nil] pagination cursor for :cursor mode
56
+ #
57
+ # @return [PlanMyStuff::BaseProject]
58
+ #
59
+ def find(number, paginate: :auto, cursor: nil)
60
+ org = PlanMyStuff.configuration.organization
61
+
62
+ case paginate
63
+ when :auto
64
+ find_auto_paginated(org, number)
65
+ when :cursor
66
+ find_with_cursor(org, number, cursor: cursor)
67
+ else
68
+ raise(ArgumentError, "Unknown paginate mode: #{paginate.inspect}. Use :auto or :cursor")
69
+ end
70
+ end
71
+
72
+ # Generic list - returns all projects in the configured organization, each dispatched to its concrete type
73
+ # (Project or TestingProject). 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. The copy inherits all custom fields and
139
+ # board layout from the source. Returns the newly created project via +find+, dispatched to the correct concrete
140
+ # subclass (Project or TestingProject) based on the cloned readme.
141
+ #
142
+ # @param source_number [Integer] project number of the project to copy from
143
+ # @param title [String] title for the new project
144
+ #
145
+ # @return [PlanMyStuff::BaseProject]
146
+ #
147
+ def clone!(source_number:, title:)
148
+ org = PlanMyStuff.configuration.organization
149
+ org_id = resolve_org_id(org)
150
+ source_project_id = resolve_project_id(org, source_number)
151
+
152
+ data = PlanMyStuff.client.graphql(
153
+ PlanMyStuff::GraphQL::Queries::COPY_PROJECT_V2,
154
+ variables: {
155
+ input: {
156
+ ownerId: org_id,
157
+ projectId: source_project_id,
158
+ title: title,
159
+ includeDraftIssues: false,
160
+ },
161
+ },
162
+ )
163
+
164
+ new_number = data.dig(:copyProjectV2, :projectV2, :number)
165
+ PlanMyStuff::BaseProject.find(new_number)
166
+ end
167
+
168
+ # Resolves a project number, falling back to config.default_project_number.
169
+ #
170
+ # @raise [ArgumentError] if project_number is nil and config.default_project_number is not set
171
+ #
172
+ # @param project_number [Integer, nil]
173
+ #
174
+ # @return [Integer]
175
+ #
176
+ def resolve_default_project_number!(project_number)
177
+ return project_number if project_number.present?
178
+
179
+ PlanMyStuff.configuration.default_project_number ||
180
+ raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
181
+ end
182
+
183
+ private
184
+
185
+ # Returns the appropriate project class based on the metadata kind field. Always dispatches to a concrete
186
+ # subclass (never BaseProject itself).
187
+ #
188
+ # @param meta_hash [Hash]
189
+ #
190
+ # @return [Class]
191
+ #
192
+ def dispatch_project_class(meta_hash)
193
+ return PlanMyStuff::TestingProject if meta_hash[:kind] == 'testing'
194
+
195
+ PlanMyStuff::Project
196
+ end
197
+
198
+ # Resolves a project number to its node ID.
199
+ #
200
+ # @param org [String]
201
+ # @param project_number [Integer]
202
+ #
203
+ # @return [String]
204
+ #
205
+ def resolve_project_id(org, project_number)
206
+ data = PlanMyStuff.client.graphql(
207
+ PlanMyStuff::GraphQL::Queries::PROJECT_ID,
208
+ variables: { org: org, number: project_number },
209
+ )
210
+
211
+ data.dig(:organization, :projectV2, :id)
212
+ end
213
+
214
+ # Resolves an organization login to its node ID.
215
+ #
216
+ # @param org [String]
217
+ #
218
+ # @return [String]
219
+ #
220
+ def resolve_org_id(org)
221
+ data = PlanMyStuff.client.graphql(
222
+ PlanMyStuff::GraphQL::Queries::ORG_ID,
223
+ variables: { org: org },
224
+ )
225
+
226
+ data.dig(:organization, :id)
227
+ end
228
+ end
229
+
230
+ # Returns the Status single-select field definition.
231
+ #
232
+ # @return [Hash] with :id and :options keys
233
+ #
234
+ def status_field
235
+ status_field!
236
+ rescue PlanMyStuff::APIError
237
+ nil
238
+ end
239
+
240
+ # Returns the Status single-select field definition.
241
+ #
242
+ # @raise [PlanMyStuff::APIError] if no Status field exists
243
+ #
244
+ # @return [Hash] with :id and :options keys
245
+ #
246
+ def status_field!
247
+ status_field = fields.find { |f| f[:name] == 'Status' && f[:options] }
248
+
249
+ raise(PlanMyStuff::APIError, "No 'Status' field found on project ##{number}") if status_field.nil?
250
+
251
+ status_field
252
+ end
253
+
254
+ # @return [Boolean]
255
+ def pms_project?
256
+ metadata.schema_version.present?
257
+ end
258
+
259
+ # Persists the project. Creates if new, updates if persisted.
260
+ #
261
+ # @return [self]
262
+ #
263
+ def save!
264
+ if new_record?
265
+ created = self.class.create!(
266
+ title: title,
267
+ readme: readme || '',
268
+ description: description,
269
+ **create_kwargs_from_metadata,
270
+ )
271
+ hydrate_from_project(created)
272
+ else
273
+ update!(
274
+ title: title,
275
+ readme: readme,
276
+ description: description,
277
+ metadata: metadata_for_update,
278
+ )
279
+ end
280
+
281
+ self
282
+ end
283
+
284
+ # Updates this project on GitHub. Raises StaleObjectError if the remote has been modified since this instance was
285
+ # loaded.
286
+ #
287
+ # @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
288
+ #
289
+ # @return [self]
290
+ #
291
+ def update!(**attrs)
292
+ raise_if_stale!
293
+
294
+ self.class.update!(
295
+ project_number: number,
296
+ **attrs,
297
+ )
298
+
299
+ reload
300
+ end
301
+
302
+ # Re-fetches this project from GitHub and updates all local attributes.
303
+ #
304
+ # @return [self]
305
+ #
306
+ def reload
307
+ fresh = self.class.find(number)
308
+ hydrate_from_project(fresh)
309
+ self
310
+ end
311
+
312
+ private
313
+
314
+ # Kwargs derived from +metadata+ for forwarding to +self.class.create!+ so +save!+ preserves in-memory metadata
315
+ # mutations on new records. Subclasses override to include type-specific metadata attributes.
316
+ #
317
+ # @return [Hash]
318
+ #
319
+ def create_kwargs_from_metadata
320
+ {
321
+ user: metadata.created_by,
322
+ visibility: metadata.visibility,
323
+ custom_fields: metadata.custom_fields.to_h,
324
+ }
325
+ end
326
+
327
+ # Metadata hash forwarded to the class +update!+ so +save!+ preserves in-memory metadata mutations on persisted
328
+ # records. Returns +nil+ when the instance has no PMS metadata to avoid clobbering remote values.
329
+ #
330
+ # @return [Hash, nil]
331
+ #
332
+ def metadata_for_update
333
+ return if metadata.schema_version.blank?
334
+
335
+ metadata.to_h
336
+ end
337
+
338
+ # Populates this instance from a list query node (summary only).
339
+ #
340
+ # @param node [Hash]
341
+ #
342
+ # @return [void]
343
+ #
344
+ def hydrate_summary(node, raw_readme:, parsed_meta:)
345
+ @github_response = node
346
+ self.id = node[:id]
347
+ self.number = node[:number]
348
+ self.title = node[:title]
349
+ self.description = node[:shortDescription]
350
+ self.url = node[:url]
351
+ self.closed = node[:closed]
352
+ self.updated_at = parse_github_time(node[:updatedAt])
353
+ self.raw_readme = raw_readme
354
+ self.metadata = build_project_metadata(parsed_meta[:metadata])
355
+ self.readme = parsed_meta[:body]
356
+ persisted!
357
+ end
358
+
359
+ # Populates this instance from a detailed find query response.
360
+ #
361
+ # @param graphql_project [Hash]
362
+ # @param items [Array<Hash>]
363
+ # @param next_cursor [String, nil]
364
+ # @param has_next_page [Boolean, nil]
365
+ #
366
+ # @return [void]
367
+ #
368
+ def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
369
+ @github_response = graphql_project
370
+ self.id = graphql_project[:id]
371
+ self.number = graphql_project[:number]
372
+ self.title = graphql_project[:title]
373
+ self.description = graphql_project[:shortDescription]
374
+ self.url = graphql_project[:url]
375
+ self.closed = graphql_project[:closed]
376
+ self.updated_at = parse_github_time(graphql_project[:updatedAt])
377
+
378
+ self.raw_readme = graphql_project[:readme] || ''
379
+ parsed = PlanMyStuff::MetadataParser.parse(raw_readme)
380
+ self.metadata = build_project_metadata(parsed[:metadata])
381
+ self.readme = parsed[:body]
382
+
383
+ fields_nodes = graphql_project.dig(:fields, :nodes) || []
384
+ self.statuses = extract_statuses(fields_nodes)
385
+ self.fields = extract_fields(fields_nodes)
386
+ self.items = items.map { |item_hash| item_class.build(item_hash, project: self) }
387
+ self.next_cursor = next_cursor
388
+ self.has_next_page = has_next_page
389
+
390
+ status_field!
391
+
392
+ persisted!
393
+ end
394
+
395
+ # Copies attributes from another Project instance into self.
396
+ #
397
+ # @param other [PlanMyStuff::BaseProject]
398
+ #
399
+ # @return [void]
400
+ #
401
+ def hydrate_from_project(other)
402
+ @github_response = other.github_response
403
+ self.id = other.id
404
+ self.number = other.number
405
+ self.title = other.title
406
+ self.description = other.description
407
+ self.url = other.url
408
+ self.closed = other.closed
409
+ self.updated_at = other.updated_at
410
+ self.raw_readme = other.raw_readme
411
+ self.readme = other.readme
412
+ self.metadata = other.metadata
413
+ self.statuses = other.statuses
414
+ self.fields = other.fields
415
+ self.items = other.items
416
+ self.next_cursor = other.next_cursor
417
+ self.has_next_page = other.has_next_page
418
+
419
+ status_field!
420
+
421
+ persisted!
422
+ end
423
+
424
+ # Raises StaleObjectError if the remote project has been modified since this instance was loaded.
425
+ #
426
+ # @raise [PlanMyStuff::StaleObjectError]
427
+ #
428
+ # @return [void]
429
+ #
430
+ def raise_if_stale!
431
+ return if new_record?
432
+ return if updated_at.nil?
433
+
434
+ remote = self.class.find(number)
435
+ remote_time = remote.updated_at
436
+ local_time = updated_at
437
+
438
+ return if remote_time.nil?
439
+ return if local_time && remote_time.to_i == local_time.to_i
440
+
441
+ raise(PlanMyStuff::StaleObjectError.new(
442
+ "Project ##{number} has been modified remotely",
443
+ local_updated_at: local_time,
444
+ remote_updated_at: remote_time,
445
+ ))
446
+ end
447
+
448
+ # Builds the appropriate metadata object for this project instance. Dispatches to TestingProjectMetadata when
449
+ # kind is "testing".
450
+ #
451
+ # @param meta_hash [Hash]
452
+ #
453
+ # @return [PlanMyStuff::BaseProjectMetadata]
454
+ #
455
+ def build_project_metadata(meta_hash)
456
+ if meta_hash[:kind] == 'testing'
457
+ PlanMyStuff::TestingProjectMetadata.from_hash(meta_hash)
458
+ else
459
+ PlanMyStuff::ProjectMetadata.from_hash(meta_hash)
460
+ end
461
+ end
462
+
463
+ # Extracts status options from the "Status" single-select field.
464
+ #
465
+ # @param fields_nodes [Array<Hash>]
466
+ #
467
+ # @return [Array<Hash>]
468
+ #
469
+ def extract_statuses(fields_nodes)
470
+ field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
471
+
472
+ return [] unless field
473
+
474
+ (field[:options] || []).map do |opt|
475
+ { id: opt[:id], name: opt[:name] }
476
+ end
477
+ end
478
+
479
+ # @param fields_nodes [Array<Hash>]
480
+ #
481
+ # @return [Array<Hash>]
482
+ #
483
+ def extract_fields(fields_nodes)
484
+ fields_nodes.map do |f|
485
+ field = { id: f[:id], name: f[:name] }
486
+ field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
487
+ field
488
+ end
489
+ end
490
+
491
+ # Returns the item class used to build items for this project type. Subclasses override to return a
492
+ # domain-specific item class.
493
+ #
494
+ # @raise [NotImplementedError] if not implemented in subclass
495
+ #
496
+ # @return [Class]
497
+ #
498
+ def item_class
499
+ raise(NotImplementedError, 'Subclass must implement #item_class to return the appropriate item class')
500
+ end
501
+ end
502
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module BaseProjectExtractions
5
+ module GraphqlHydration
6
+ private
7
+
8
+ # Builds a summary Project from a list query node. Dispatches to TestingProject when the readme metadata has
9
+ # kind: "testing".
10
+ #
11
+ # @param node [Hash]
12
+ #
13
+ # @return [PlanMyStuff::BaseProject]
14
+ #
15
+ def build_summary(node)
16
+ raw_readme = node[:readme] || ''
17
+ parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
18
+ klass = dispatch_project_class(parsed_meta[:metadata])
19
+ project = klass.new
20
+ project.__send__(:hydrate_summary, node, raw_readme: raw_readme, parsed_meta: parsed_meta)
21
+ project
22
+ end
23
+
24
+ # Builds a detailed Project from a find query response. Dispatches to TestingProject when the readme metadata
25
+ # has kind: "testing".
26
+ #
27
+ # @param graphql_project [Hash]
28
+ # @param items [Array<Hash>]
29
+ # @param next_cursor [String, nil]
30
+ # @param has_next_page [Boolean, nil]
31
+ #
32
+ # @return [PlanMyStuff::BaseProject]
33
+ #
34
+ def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
35
+ raw_readme = graphql_project[:readme] || ''
36
+ parsed_meta = PlanMyStuff::MetadataParser.parse(raw_readme)
37
+ klass = dispatch_project_class(parsed_meta[:metadata])
38
+ project = klass.new
39
+ project.__send__(
40
+ :hydrate_detail,
41
+ graphql_project,
42
+ items: items,
43
+ next_cursor: next_cursor,
44
+ has_next_page: has_next_page,
45
+ )
46
+ project
47
+ end
48
+
49
+ # @param org [String]
50
+ # @param number [Integer]
51
+ #
52
+ # @return [PlanMyStuff::BaseProject]
53
+ #
54
+ def find_auto_paginated(org, number)
55
+ all_items = []
56
+ cursor = nil
57
+ raw_project = nil
58
+ page = nil
59
+
60
+ loop do
61
+ page = fetch_project_page(org, number, cursor)
62
+ raw_project ||= page[:raw]
63
+ all_items.concat(page[:items])
64
+
65
+ break if !page[:has_next_page] || all_items.length >= PlanMyStuff::BaseProject::MAX_AUTO_PAGINATE_ITEMS
66
+
67
+ cursor = page[:next_cursor]
68
+ end
69
+
70
+ build_detail(
71
+ raw_project,
72
+ items: all_items,
73
+ next_cursor: page[:next_cursor],
74
+ has_next_page: page[:has_next_page],
75
+ )
76
+ end
77
+
78
+ # @param org [String]
79
+ # @param number [Integer]
80
+ # @param cursor [String, nil]
81
+ #
82
+ # @return [PlanMyStuff::BaseProject]
83
+ #
84
+ def find_with_cursor(org, number, cursor:)
85
+ page = fetch_project_page(org, number, cursor)
86
+ build_detail(
87
+ page[:raw],
88
+ items: page[:items],
89
+ next_cursor: page[:next_cursor],
90
+ has_next_page: page[:has_next_page],
91
+ )
92
+ end
93
+
94
+ # Fetches a single page of project data. Returns a lightweight hash for pagination loop consumption (not a
95
+ # Project instance).
96
+ #
97
+ # @param org [String]
98
+ # @param number [Integer]
99
+ # @param cursor [String, nil]
100
+ #
101
+ # @return [Hash] with :raw, :items, :next_cursor, :has_next_page
102
+ #
103
+ def fetch_project_page(org, number, cursor)
104
+ variables = { org: org, number: number }
105
+ variables[:cursor] = cursor if cursor
106
+
107
+ data = PlanMyStuff.client.graphql(
108
+ PlanMyStuff::GraphQL::Queries::FIND_PROJECT,
109
+ variables: variables,
110
+ )
111
+
112
+ raw_project = data.dig(:organization, :projectV2)
113
+ page_info = raw_project.dig(:items, :pageInfo) || {}
114
+ items_data = raw_project.dig(:items, :nodes) || []
115
+
116
+ {
117
+ raw: raw_project,
118
+ items: items_data.map { |item| parse_project_item(item) },
119
+ next_cursor: page_info[:endCursor],
120
+ has_next_page: page_info[:hasNextPage],
121
+ }
122
+ end
123
+
124
+ # @param item [Hash] raw GraphQL project item node
125
+ #
126
+ # @return [Hash]
127
+ #
128
+ def parse_project_item(item)
129
+ content = item[:content] || {}
130
+ field_values = item.dig(:fieldValues, :nodes) || []
131
+ repo_name = content.dig(:repository, :nameWithOwner)
132
+ assignee_nodes = content.dig(:assignees, :nodes) || []
133
+
134
+ {
135
+ id: item[:id],
136
+ type: item[:type],
137
+ content_node_id: content[:id],
138
+ title: content[:title],
139
+ body: content[:body],
140
+ number: content[:number],
141
+ url: content[:url],
142
+ state: content[:state],
143
+ repo: repo_name.present? ? PlanMyStuff::Repo.resolve!(repo_name) : nil,
144
+ status: extract_item_status(field_values),
145
+ field_values: parse_field_values(field_values),
146
+ assignees: assignee_nodes.pluck(:login).compact,
147
+ updated_at: item[:updatedAt],
148
+ github_response: item,
149
+ }
150
+ end
151
+
152
+ # @param field_values [Array<Hash>]
153
+ #
154
+ # @return [String, nil]
155
+ #
156
+ def extract_item_status(field_values)
157
+ status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
158
+
159
+ status_value&.dig(:name)
160
+ end
161
+
162
+ # @param field_values [Array<Hash>]
163
+ #
164
+ # @return [Hash]
165
+ #
166
+ def parse_field_values(field_values)
167
+ result = {}
168
+
169
+ field_values.each do |fv|
170
+ field_name = fv.dig(:field, :name)
171
+ next unless field_name
172
+
173
+ value = fv[:name] || fv[:text] || fv[:date]&.to_date
174
+ users_node = fv[:users]
175
+ if users_node
176
+ value = (users_node[:nodes] || []).map { |u| u[:login] }
177
+ end
178
+
179
+ result[field_name] = value
180
+ end
181
+
182
+ result
183
+ end
184
+ end
185
+ end
186
+ end