plan_my_stuff 0.3.0 → 0.5.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 +22 -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 +172 -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 +216 -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 +179 -0
  83. metadata +77 -3
@@ -1,51 +1,19 @@
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
- # @return [PlanMyStuff::ProjectMetadata] parsed metadata (empty when no PMS metadata present)
22
- attr_reader :metadata
23
- # @return [String] full readme as stored on GitHub
24
- attr_reader :raw_readme
25
- # @return [String, nil] project short description (from shortDescription)
26
- attr_reader :description
27
- # @return [Time, nil] GitHub's updatedAt timestamp
28
- attr_reader :updated_at
29
-
30
- # @return [String] project title
31
- attr_accessor :title
32
- # @return [String] project URL
33
- attr_accessor :url
34
- # @return [String, nil] user-visible readme content (without metadata comment)
35
- attr_accessor :readme
36
- # @return [Array<Hash>] status options ({id:, name:})
37
- attr_accessor :statuses
38
- # @return [Array<Hash>] all field definitions
39
- attr_accessor :fields
40
- # @return [Array<PlanMyStuff::ProjectItem>] project items
41
- attr_accessor :items
42
- # @return [String, nil] cursor for next page (only in cursor mode)
43
- attr_accessor :next_cursor
44
- # @return [Boolean, nil] whether more pages exist (only in cursor mode)
45
- attr_accessor :has_next_page
46
-
14
+ class Project < PlanMyStuff::BaseProject
47
15
  class << self
48
- # Creates a new project in the configured organization with PMS metadata.
16
+ # Creates a new regular project in the configured organization with PMS metadata.
49
17
  #
50
18
  # @param title [String]
51
19
  # @param user [Object, Integer, nil] user object or user_id
@@ -68,7 +36,7 @@ module PlanMyStuff
68
36
 
69
37
  org_id = resolve_org_id(org)
70
38
  data = PlanMyStuff.client.graphql(
71
- create_mutation,
39
+ PlanMyStuff::GraphQL::Queries::CREATE_PROJECT,
72
40
  variables: { input: { ownerId: org_id, title: title } },
73
41
  )
74
42
 
@@ -81,685 +49,54 @@ module PlanMyStuff
81
49
  update_input[:shortDescription] = description if description.present?
82
50
 
83
51
  PlanMyStuff.client.graphql(
84
- update_mutation,
52
+ PlanMyStuff::GraphQL::Queries::UPDATE_PROJECT,
85
53
  variables: { input: update_input },
86
54
  )
87
55
 
88
56
  find(project_number)
89
57
  end
90
58
 
91
- # Updates an existing project.
92
- #
93
- # @param project_number [Integer]
94
- # @param title [String, nil]
95
- # @param readme [String, nil] user-visible readme content (metadata preserved)
96
- # @param description [String, nil] project short description
97
- # @param metadata [Hash, nil] custom fields to merge into existing metadata
98
- #
99
- # @return [PlanMyStuff::Project]
100
- #
101
- def update!(project_number:, title: nil, readme: nil, description: nil, metadata: nil)
102
- org = PlanMyStuff.configuration.organization
103
- project_id = resolve_project_id(org, project_number)
104
-
105
- update_input = { projectId: project_id }
106
- update_input[:title] = title unless title.nil?
107
- update_input[:shortDescription] = description unless description.nil?
108
-
109
- if metadata.present? || !readme.nil?
110
- current = find(project_number)
111
- parsed = PlanMyStuff::MetadataParser.parse(current.raw_readme)
112
- existing_metadata = parsed[:metadata]
113
-
114
- if metadata.present?
115
- # Seed with fresh metadata when project has no existing PMS metadata
116
- if existing_metadata[:schema_version].blank?
117
- existing_metadata = PlanMyStuff::ProjectMetadata.build(user: nil).to_h
118
- end
119
-
120
- merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
121
- existing_metadata = existing_metadata.merge(metadata)
122
- existing_metadata[:custom_fields] = merged_custom_fields
123
- PlanMyStuff::CustomFields.new(
124
- PlanMyStuff.configuration.custom_fields_for(:project),
125
- merged_custom_fields,
126
- ).validate!
127
- end
128
-
129
- body = readme.nil? ? parsed[:body] : readme
130
- update_input[:readme] = PlanMyStuff::MetadataParser.serialize(existing_metadata, body)
131
- end
132
-
133
- PlanMyStuff.client.graphql(
134
- update_mutation,
135
- variables: { input: update_input },
136
- )
137
-
138
- find(project_number)
139
- end
140
-
141
- # Lists all projects in the configured organization.
142
- #
143
- # @return [Array<PlanMyStuff::Project>]
144
- #
145
- def list
146
- org = PlanMyStuff.configuration.organization
147
- data = PlanMyStuff.client.graphql(list_query, variables: { org: org })
148
-
149
- nodes = data.dig(:organization, :projectsV2, :nodes) || []
150
-
151
- nodes.map { |node| build_summary(node) }
152
- end
153
-
154
- # 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.
155
62
  #
156
63
  # @param number [Integer]
157
64
  # @param paginate [Symbol] :auto (default) or :cursor
158
- # @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
159
68
  #
160
69
  # @return [PlanMyStuff::Project]
161
70
  #
162
71
  def find(number, paginate: :auto, cursor: nil)
163
- org = PlanMyStuff.configuration.organization
72
+ project = super
164
73
 
165
- case paginate
166
- when :auto
167
- find_auto_paginated(org, number)
168
- when :cursor
169
- find_with_cursor(org, number, cursor: cursor)
170
- else
171
- 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")
172
76
  end
77
+
78
+ project
173
79
  end
174
80
 
175
- # Resolves a project number, falling back to config.default_project_number.
81
+ # Lists all regular projects in the configured organization.
82
+ # Testing projects are excluded; use +TestingProject.list+ or
83
+ # +BaseProject.list+ to reach them.
176
84
  #
177
- # @param project_number [Integer, nil]
178
- #
179
- # @return [Integer]
85
+ # @return [Array<PlanMyStuff::Project>]
180
86
  #
181
- def resolve_default_project_number(project_number)
182
- return project_number if project_number.present?
183
-
184
- PlanMyStuff.configuration.default_project_number ||
185
- raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
186
- end
187
-
188
- private
189
-
190
- # @return [String]
191
- def list_query
192
- <<~GRAPHQL
193
- query($org: String!) {
194
- organization(login: $org) {
195
- projectsV2(first: 100) {
196
- nodes {
197
- id
198
- number
199
- title
200
- shortDescription
201
- url
202
- closed
203
- updatedAt
204
- }
205
- }
206
- }
207
- }
208
- GRAPHQL
209
- end
210
-
211
- # @return [String]
212
- def find_query
213
- <<~GRAPHQL
214
- query($org: String!, $number: Int!, $cursor: String) {
215
- organization(login: $org) {
216
- projectV2(number: $number) {
217
- id
218
- number
219
- title
220
- shortDescription
221
- readme
222
- url
223
- closed
224
- updatedAt
225
- fields(first: 50) {
226
- nodes {
227
- ... on ProjectV2SingleSelectField {
228
- id
229
- name
230
- options {
231
- id
232
- name
233
- }
234
- }
235
- ... on ProjectV2Field {
236
- id
237
- name
238
- }
239
- ... on ProjectV2IterationField {
240
- id
241
- name
242
- }
243
- }
244
- }
245
- items(first: #{ITEMS_PER_PAGE}, after: $cursor) {
246
- pageInfo {
247
- hasNextPage
248
- endCursor
249
- }
250
- nodes {
251
- id
252
- type
253
- content {
254
- ... on Issue {
255
- id
256
- title
257
- number
258
- url
259
- state
260
- repository { nameWithOwner }
261
- }
262
- ... on PullRequest {
263
- id
264
- title
265
- number
266
- url
267
- state
268
- repository { nameWithOwner }
269
- }
270
- ... on DraftIssue {
271
- id
272
- title
273
- }
274
- }
275
- fieldValues(first: 20) {
276
- nodes {
277
- ... on ProjectV2ItemFieldSingleSelectValue {
278
- name
279
- field {
280
- ... on ProjectV2SingleSelectField {
281
- name
282
- }
283
- }
284
- }
285
- ... on ProjectV2ItemFieldTextValue {
286
- text
287
- field {
288
- ... on ProjectV2Field {
289
- name
290
- }
291
- }
292
- }
293
- ... on ProjectV2ItemFieldUserValue {
294
- users(first: 10) {
295
- nodes {
296
- login
297
- }
298
- }
299
- field {
300
- ... on ProjectV2Field {
301
- name
302
- }
303
- }
304
- }
305
- }
306
- }
307
- }
308
- }
309
- }
310
- }
311
- }
312
- GRAPHQL
313
- end
314
-
315
- # @return [String]
316
- def project_id_query
317
- <<~GRAPHQL
318
- query($org: String!, $number: Int!) {
319
- organization(login: $org) {
320
- projectV2(number: $number) {
321
- id
322
- }
323
- }
324
- }
325
- GRAPHQL
326
- end
327
-
328
- # Builds a summary Project from a list query node.
329
- #
330
- # @param node [Hash]
331
- #
332
- # @return [PlanMyStuff::Project]
333
- #
334
- def build_summary(node)
335
- project = new
336
- project.__send__(:hydrate_summary, node)
337
- project
338
- end
339
-
340
- # Builds a detailed Project from a find query response.
341
- #
342
- # @param graphql_project [Hash]
343
- # @param items [Array<Hash>]
344
- # @param next_cursor [String, nil]
345
- # @param has_next_page [Boolean, nil]
346
- #
347
- # @return [PlanMyStuff::Project]
348
- #
349
- def build_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
350
- project = new
351
- project.__send__(
352
- :hydrate_detail,
353
- graphql_project,
354
- items: items,
355
- next_cursor: next_cursor,
356
- has_next_page: has_next_page,
357
- )
358
- project
359
- end
360
-
361
- # @param org [String]
362
- # @param number [Integer]
363
- #
364
- # @return [PlanMyStuff::Project]
365
- #
366
- def find_auto_paginated(org, number)
367
- all_items = []
368
- cursor = nil
369
- raw_project = nil
370
-
371
- loop do
372
- page = fetch_project_page(org, number, cursor)
373
- raw_project ||= page[:raw]
374
- all_items.concat(page[:items])
375
-
376
- break if !page[:has_next_page] || all_items.length >= MAX_AUTO_PAGINATE_ITEMS
377
-
378
- cursor = page[:next_cursor]
379
- end
380
-
381
- build_detail(raw_project, items: all_items)
382
- end
383
-
384
- # @param org [String]
385
- # @param number [Integer]
386
- # @param cursor [String, nil]
387
- #
388
- # @return [PlanMyStuff::Project]
389
- #
390
- def find_with_cursor(org, number, cursor:)
391
- page = fetch_project_page(org, number, cursor)
392
- build_detail(
393
- page[:raw],
394
- items: page[:items],
395
- next_cursor: page[:next_cursor],
396
- has_next_page: page[:has_next_page],
397
- )
398
- end
399
-
400
- # Fetches a single page of project data. Returns a lightweight hash
401
- # for pagination loop consumption (not a Project instance).
402
- #
403
- # @param org [String]
404
- # @param number [Integer]
405
- # @param cursor [String, nil]
406
- #
407
- # @return [Hash] with :raw, :items, :next_cursor, :has_next_page
408
- #
409
- def fetch_project_page(org, number, cursor)
410
- variables = { org: org, number: number }
411
- variables[:cursor] = cursor if cursor
412
-
413
- data = PlanMyStuff.client.graphql(find_query, variables: variables)
414
-
415
- raw_project = data.dig(:organization, :projectV2)
416
- page_info = raw_project.dig(:items, :pageInfo) || {}
417
- items_data = raw_project.dig(:items, :nodes) || []
418
-
419
- {
420
- raw: raw_project,
421
- items: items_data.map { |item| parse_project_item(item) },
422
- next_cursor: page_info[:endCursor],
423
- has_next_page: page_info[:hasNextPage],
424
- }
425
- end
426
-
427
- # @param item [Hash] raw GraphQL project item node
428
- #
429
- # @return [Hash]
430
- #
431
- def parse_project_item(item)
432
- content = item[:content] || {}
433
- field_values = item.dig(:fieldValues, :nodes) || []
434
- repo_name = content.dig(:repository, :nameWithOwner)
435
-
436
- {
437
- id: item[:id],
438
- type: item[:type],
439
- content_node_id: content[:id],
440
- title: content[:title],
441
- number: content[:number],
442
- url: content[:url],
443
- state: content[:state],
444
- repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
445
- status: extract_item_status(field_values),
446
- field_values: parse_field_values(field_values),
447
- }
448
- end
449
-
450
- # @param field_values [Array<Hash>]
451
- #
452
- # @return [String, nil]
453
- #
454
- def extract_item_status(field_values)
455
- status_value = field_values.find { |fv| fv.dig(:field, :name) == 'Status' }
456
-
457
- status_value&.dig(:name)
458
- end
459
-
460
- # @param field_values [Array<Hash>]
461
- #
462
- # @return [Hash]
463
- #
464
- def parse_field_values(field_values)
465
- result = {}
466
-
467
- field_values.each do |fv|
468
- field_name = fv.dig(:field, :name)
469
- next unless field_name
470
-
471
- value = fv[:name] || fv[:text]
472
- users_node = fv[:users]
473
- if users_node
474
- value = (users_node[:nodes] || []).map { |u| u[:login] }
475
- end
476
-
477
- result[field_name] = value
478
- end
479
-
480
- result
481
- end
482
-
483
- # Resolves a project number to its node ID.
484
- #
485
- # @param org [String]
486
- # @param project_number [Integer]
487
- #
488
- # @return [String]
489
- #
490
- def resolve_project_id(org, project_number)
491
- data = PlanMyStuff.client.graphql(
492
- project_id_query,
493
- variables: { org: org, number: project_number },
494
- )
495
-
496
- data.dig(:organization, :projectV2, :id)
497
- end
498
-
499
- # Resolves an organization login to its node ID.
500
- #
501
- # @param org [String]
502
- #
503
- # @return [String]
504
- #
505
- def resolve_org_id(org)
506
- data = PlanMyStuff.client.graphql(
507
- org_id_query,
508
- variables: { org: org },
509
- )
510
-
511
- data.dig(:organization, :id)
512
- end
513
-
514
- # @return [String]
515
- def org_id_query
516
- <<~GRAPHQL
517
- query($org: String!) {
518
- organization(login: $org) {
519
- id
520
- }
521
- }
522
- GRAPHQL
523
- end
524
-
525
- # @return [String]
526
- def create_mutation
527
- <<~GRAPHQL
528
- mutation($input: CreateProjectV2Input!) {
529
- createProjectV2(input: $input) {
530
- projectV2 {
531
- id
532
- number
533
- }
534
- }
535
- }
536
- GRAPHQL
537
- end
538
-
539
- # @return [String]
540
- def update_mutation
541
- <<~GRAPHQL
542
- mutation($input: UpdateProjectV2Input!) {
543
- updateProjectV2(input: $input) {
544
- projectV2 {
545
- id
546
- number
547
- }
548
- }
549
- }
550
- GRAPHQL
551
- end
552
- end
553
-
554
- # @see super
555
- def initialize(**attrs)
556
- @id = attrs.delete(:id)
557
- @number = attrs.delete(:number)
558
- @closed = attrs.delete(:closed)
559
- @metadata = PlanMyStuff::ProjectMetadata.new
560
- @raw_readme = nil
561
- @readme = nil
562
- @description = nil
563
- @updated_at = nil
564
- super
565
- @statuses ||= []
566
- @fields ||= []
567
- @items ||= []
568
- end
569
-
570
- # Returns the Status single-select field definition.
571
- #
572
- # @return [Hash] with :id and :options keys
573
- #
574
- # @raise [PlanMyStuff::APIError] if no Status field exists
575
- #
576
- def status_field
577
- field = fields.find { |f| f[:name] == 'Status' && f[:options] }
578
-
579
- raise(APIError, "No 'Status' field found on project ##{number}") unless field
580
-
581
- field
582
- end
583
-
584
- # @return [Boolean]
585
- def pms_project?
586
- metadata.schema_version.present?
587
- end
588
-
589
- # Persists the project. Creates if new, updates if persisted.
590
- #
591
- # @raise [PlanMyStuff::StaleObjectError] on update if stale
592
- #
593
- # @return [self]
594
- #
595
- def save!
596
- if new_record?
597
- created = self.class.create!(title: title, readme: readme || '')
598
- hydrate_from_project(created)
599
- else
600
- update!(title: title, readme: readme)
87
+ def list
88
+ super.select { |p| p.is_a?(self) }
601
89
  end
602
-
603
- self
604
- end
605
-
606
- # Updates this project on GitHub. Raises StaleObjectError if the remote
607
- # has been modified since this instance was loaded.
608
- #
609
- # @param attrs [Hash] attributes to update (title:, readme:, description:, metadata:)
610
- #
611
- # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
612
- #
613
- # @return [self]
614
- #
615
- def update!(**attrs)
616
- raise_if_stale!
617
-
618
- self.class.update!(
619
- project_number: number,
620
- **attrs,
621
- )
622
-
623
- reload
624
- end
625
-
626
- # Re-fetches this project from GitHub and updates all local attributes.
627
- #
628
- # @return [self]
629
- #
630
- def reload
631
- fresh = self.class.find(number)
632
- hydrate_from_project(fresh)
633
- self
634
90
  end
635
91
 
636
92
  private
637
93
 
638
- # Populates this instance from a list query node (summary only).
94
+ # Returns the item class used to build items for testing projects.
639
95
  #
640
- # @param node [Hash]
96
+ # @return [Class]
641
97
  #
642
- # @return [void]
643
- #
644
- def hydrate_summary(node)
645
- @id = node[:id]
646
- @number = node[:number]
647
- @title = node[:title]
648
- @description = node[:shortDescription]
649
- @url = node[:url]
650
- @closed = node[:closed]
651
- @updated_at = parse_github_time(node[:updatedAt])
652
- @persisted = true
653
- end
654
-
655
- # Populates this instance from a detailed find query response.
656
- #
657
- # @param graphql_project [Hash]
658
- # @param items [Array<Hash>]
659
- # @param next_cursor [String, nil]
660
- # @param has_next_page [Boolean, nil]
661
- #
662
- # @return [void]
663
- #
664
- def hydrate_detail(graphql_project, items:, next_cursor: nil, has_next_page: nil)
665
- @id = graphql_project[:id]
666
- @number = graphql_project[:number]
667
- @title = graphql_project[:title]
668
- @description = graphql_project[:shortDescription]
669
- @url = graphql_project[:url]
670
- @closed = graphql_project[:closed]
671
- @updated_at = parse_github_time(graphql_project[:updatedAt])
672
-
673
- @raw_readme = graphql_project[:readme] || ''
674
- parsed = PlanMyStuff::MetadataParser.parse(@raw_readme)
675
- @metadata = PlanMyStuff::ProjectMetadata.from_hash(parsed[:metadata])
676
- @readme = parsed[:body]
677
-
678
- fields_nodes = graphql_project.dig(:fields, :nodes) || []
679
- @statuses = extract_statuses(fields_nodes)
680
- @fields = extract_fields(fields_nodes)
681
- @items = items.map { |item_hash| ProjectItem.build(item_hash, project: self) }
682
- @next_cursor = next_cursor
683
- @has_next_page = has_next_page
684
- @persisted = true
685
- end
686
-
687
- # Copies attributes from another Project instance into self.
688
- #
689
- # @param other [PlanMyStuff::Project]
690
- #
691
- # @return [void]
692
- #
693
- def hydrate_from_project(other)
694
- @id = other.id
695
- @number = other.number
696
- @title = other.title
697
- @description = other.description
698
- @url = other.url
699
- @closed = other.closed
700
- @updated_at = other.updated_at
701
- @raw_readme = other.raw_readme
702
- @readme = other.readme
703
- @metadata = other.metadata
704
- @statuses = other.statuses
705
- @fields = other.fields
706
- @items = other.items
707
- @next_cursor = other.next_cursor
708
- @has_next_page = other.has_next_page
709
- @persisted = true
710
- end
711
-
712
- # Raises StaleObjectError if the remote project has been modified
713
- # since this instance was loaded.
714
- #
715
- # @raise [PlanMyStuff::StaleObjectError]
716
- #
717
- # @return [void]
718
- #
719
- def raise_if_stale!
720
- return if new_record?
721
- return if updated_at.nil?
722
-
723
- remote = self.class.find(number)
724
- remote_time = remote.updated_at
725
- local_time = updated_at
726
-
727
- return if remote_time.nil?
728
- return if local_time && remote_time.to_i == local_time.to_i
729
-
730
- raise(PlanMyStuff::StaleObjectError.new(
731
- "Project ##{number} has been modified remotely",
732
- local_updated_at: local_time,
733
- remote_updated_at: remote_time,
734
- ))
735
- end
736
-
737
- # Extracts status options from the "Status" single-select field.
738
- #
739
- # @param fields_nodes [Array<Hash>]
740
- #
741
- # @return [Array<Hash>]
742
- #
743
- def extract_statuses(fields_nodes)
744
- status_field = fields_nodes.find { |f| f[:name] == 'Status' && f.key?(:options) }
745
-
746
- return [] unless status_field
747
-
748
- (status_field[:options] || []).map do |opt|
749
- { id: opt[:id], name: opt[:name] }
750
- end
751
- end
752
-
753
- # @param fields_nodes [Array<Hash>]
754
- #
755
- # @return [Array<Hash>]
756
- #
757
- def extract_fields(fields_nodes)
758
- fields_nodes.map do |f|
759
- field = { id: f[:id], name: f[:name] }
760
- field[:options] = f[:options].map { |o| { id: o[:id], name: o[:name] } } if f[:options]
761
- field
762
- end
98
+ def item_class
99
+ PlanMyStuff::ProjectItem
763
100
  end
764
101
  end
765
102
  end