plan_my_stuff 0.7.0 → 0.8.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/README.md +100 -103
  4. data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
  6. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
  7. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
  8. data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
  9. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
  10. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
  11. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
  12. data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
  13. data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
  14. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
  15. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
  16. data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
  17. data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
  18. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
  19. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
  20. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
  21. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
  22. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
  23. data/app/jobs/plan_my_stuff/application_job.rb +2 -3
  24. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
  25. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  26. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
  27. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  28. data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
  31. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
  32. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
  33. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
  34. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
  35. data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
  36. data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
  37. data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
  38. data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
  39. data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
  40. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
  41. data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
  42. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
  43. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
  44. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
  45. data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
  46. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
  47. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
  49. data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
  50. data/config/routes.rb +2 -2
  51. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +51 -2
  52. data/lib/plan_my_stuff/approval.rb +12 -4
  53. data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
  54. data/lib/plan_my_stuff/base_metadata.rb +4 -15
  55. data/lib/plan_my_stuff/base_project.rb +68 -55
  56. data/lib/plan_my_stuff/base_project_item.rb +61 -57
  57. data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
  58. data/lib/plan_my_stuff/client.rb +136 -48
  59. data/lib/plan_my_stuff/comment.rb +57 -57
  60. data/lib/plan_my_stuff/comment_metadata.rb +1 -1
  61. data/lib/plan_my_stuff/configuration.rb +95 -82
  62. data/lib/plan_my_stuff/errors.rb +10 -10
  63. data/lib/plan_my_stuff/graphql/queries.rb +1 -1
  64. data/lib/plan_my_stuff/issue.rb +467 -333
  65. data/lib/plan_my_stuff/issue_metadata.rb +10 -10
  66. data/lib/plan_my_stuff/label.rb +32 -16
  67. data/lib/plan_my_stuff/link.rb +15 -15
  68. data/lib/plan_my_stuff/markdown.rb +12 -6
  69. data/lib/plan_my_stuff/metadata_parser.rb +3 -1
  70. data/lib/plan_my_stuff/notifications.rb +1 -1
  71. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
  72. data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
  73. data/lib/plan_my_stuff/pipeline.rb +61 -83
  74. data/lib/plan_my_stuff/project.rb +4 -4
  75. data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
  76. data/lib/plan_my_stuff/project_metadata.rb +1 -1
  77. data/lib/plan_my_stuff/reminders/closer.rb +1 -1
  78. data/lib/plan_my_stuff/reminders/fire.rb +3 -3
  79. data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
  80. data/lib/plan_my_stuff/repo.rb +12 -6
  81. data/lib/plan_my_stuff/test_helpers.rb +11 -11
  82. data/lib/plan_my_stuff/testing_project.rb +12 -11
  83. data/lib/plan_my_stuff/testing_project_item.rb +11 -9
  84. data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
  85. data/lib/plan_my_stuff/version.rb +1 -1
  86. data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
  87. data/lib/plan_my_stuff.rb +26 -2
  88. data/lib/tasks/plan_my_stuff.rake +33 -20
  89. metadata +3 -2
@@ -1,18 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- # Shared base for GitHub Projects V2 item wrappers. Holds attribute
5
- # definitions, generic build/create/move/update/delete/assign machinery,
6
- # and core instance helpers.
4
+ # Shared base for GitHub Projects V2 item wrappers. Holds attribute definitions, generic
5
+ # build/create/move/update/delete/assign machinery, and core instance helpers.
7
6
  #
8
- # Not meant to be used directly. Concrete subclasses (ProjectItem,
9
- # TestingProjectItem) add their own domain-specific methods.
7
+ # Not meant to be used directly. Concrete subclasses (ProjectItem, TestingProjectItem) add their own domain-specific
8
+ # methods.
10
9
  #
11
10
  # Class methods:
12
11
  # BaseProjectItem.create!(issue) -- add existing issue
13
12
  # BaseProjectItem.create!(title, draft: true, body: "...") -- create draft item
14
- # BaseProjectItem.move_item(item_id:, status:)
15
- # BaseProjectItem.assign(item_id:, assignee:)
13
+ # BaseProjectItem.move_item!(item_id:, status:)
14
+ # BaseProjectItem.assign!(item_id:, assignee:)
16
15
  #
17
16
  # Instance methods:
18
17
  # item.move_to!("Done")
@@ -101,9 +100,9 @@ module PlanMyStuff
101
100
  def create!(issue_or_title, draft: false, body: nil, project_number: nil, user: nil)
102
101
  item =
103
102
  if draft
104
- add_draft_item(title: issue_or_title, body: body, project_number: project_number)
103
+ add_draft_item!(title: issue_or_title, body: body, project_number: project_number)
105
104
  else
106
- add_item(issue: issue_or_title, project_number: project_number)
105
+ add_item!(issue: issue_or_title, project_number: project_number)
107
106
  end
108
107
 
109
108
  PlanMyStuff::Notifications.instrument('project_item.added', item, user: user)
@@ -118,12 +117,12 @@ module PlanMyStuff
118
117
  #
119
118
  # @return [Hash] the updated item
120
119
  #
121
- def move_item(item_id:, status:, project_number: nil)
122
- project_number = resolve_default_project_number(project_number)
120
+ def move_item!(item_id:, status:, project_number: nil)
121
+ project_number = resolve_default_project_number!(project_number)
123
122
  project = BaseProject.find(project_number)
124
123
 
125
124
  status_field = project.status_field
126
- option_id = resolve_status_option_id(status_field, status)
125
+ option_id = resolve_status_option_id!(status_field, status)
127
126
 
128
127
  PlanMyStuff.client.graphql(
129
128
  PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
@@ -146,11 +145,11 @@ module PlanMyStuff
146
145
  # @return [Hash] mutation result
147
146
  #
148
147
  def update_single_select_field!(item_id:, field_name:, value:, project_number: nil)
149
- project_number = resolve_default_project_number(project_number)
148
+ project_number = resolve_default_project_number!(project_number)
150
149
  project = BaseProject.find(project_number)
151
150
 
152
- field = resolve_single_select_field(project, field_name)
153
- option_id = resolve_status_option_id(field, value)
151
+ field = resolve_single_select_field!(project, field_name)
152
+ option_id = resolve_status_option_id!(field, value)
154
153
 
155
154
  PlanMyStuff.client.graphql(
156
155
  PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
@@ -173,10 +172,10 @@ module PlanMyStuff
173
172
  # @return [Hash] mutation result
174
173
  #
175
174
  def update_field!(item_id:, field_name:, value:, project_number: nil)
176
- project_number = resolve_default_project_number(project_number)
175
+ project_number = resolve_default_project_number!(project_number)
177
176
  project = BaseProject.find(project_number)
178
177
 
179
- field = resolve_text_field(project, field_name)
178
+ field = resolve_text_field!(project, field_name)
180
179
 
181
180
  PlanMyStuff.client.graphql(
182
181
  PlanMyStuff::GraphQL::Queries::UPDATE_TEXT_FIELD,
@@ -199,10 +198,10 @@ module PlanMyStuff
199
198
  # @return [Hash] mutation result
200
199
  #
201
200
  def update_date_field!(item_id:, field_name:, date:, project_number: nil)
202
- project_number = resolve_default_project_number(project_number)
201
+ project_number = resolve_default_project_number!(project_number)
203
202
  project = BaseProject.find(project_number)
204
203
 
205
- field = resolve_text_field(project, field_name)
204
+ field = resolve_text_field!(project, field_name)
206
205
 
207
206
  PlanMyStuff.client.graphql(
208
207
  PlanMyStuff::GraphQL::Queries::UPDATE_DATE_FIELD,
@@ -215,18 +214,18 @@ module PlanMyStuff
215
214
  )
216
215
  end
217
216
 
218
- # Deletes a project item from its parent project. Returns the
219
- # +deletedItemId+ from the GraphQL response on success.
217
+ # Deletes a project item from its parent project. Returns the +deletedItemId+ from the GraphQL response on
218
+ # success.
219
+ #
220
+ # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
220
221
  #
221
222
  # @param item_id [String] project item ID (e.g. "PVTI_...")
222
223
  # @param project_number [Integer, nil] defaults to config.default_project_number
223
224
  #
224
225
  # @return [String] the deleted item ID echoed back by GitHub
225
226
  #
226
- # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
227
- #
228
- def delete_item(item_id:, project_number: nil)
229
- project_number = resolve_default_project_number(project_number)
227
+ def delete_item!(item_id:, project_number: nil)
228
+ project_number = resolve_default_project_number!(project_number)
230
229
  org = PlanMyStuff.configuration.organization
231
230
  project_id = resolve_project_id(org, project_number)
232
231
 
@@ -241,8 +240,9 @@ module PlanMyStuff
241
240
  deleted_id
242
241
  end
243
242
 
244
- # Assigns users to a project item.
245
- # Issues/PRs use REST via Issue.update!, drafts use GraphQL.
243
+ # Assigns users to a project item. Issues/PRs use REST via Issue.update!, drafts use GraphQL.
244
+ #
245
+ # @raise [PlanMyStuff::APIError] if a username cannot be resolved or the GraphQL mutation fails
246
246
  #
247
247
  # @param number [Integer, nil] issue number (nil for drafts)
248
248
  # @param content_node_id [String] node ID of the underlying content
@@ -252,7 +252,7 @@ module PlanMyStuff
252
252
  #
253
253
  # @return [void]
254
254
  #
255
- def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
255
+ def assign!(number:, content_node_id:, assignees:, draft: false, repo: nil)
256
256
  if draft
257
257
  client = PlanMyStuff.client
258
258
  user_ids = assignees.map do |assignee|
@@ -262,7 +262,7 @@ module PlanMyStuff
262
262
  )
263
263
  user_id = user_data.dig(:user, :id)
264
264
 
265
- raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
265
+ raise(PlanMyStuff::APIError, "GitHub user not found: #{assignee}") if user_id.nil?
266
266
 
267
267
  user_id
268
268
  end
@@ -272,7 +272,7 @@ module PlanMyStuff
272
272
  variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
273
273
  )
274
274
  else
275
- Issue.update!(number: number, repo: repo, assignees: assignees)
275
+ PlanMyStuff::Issue.update!(number: number, repo: repo, assignees: assignees)
276
276
  end
277
277
  end
278
278
 
@@ -285,8 +285,8 @@ module PlanMyStuff
285
285
  #
286
286
  # @return [PlanMyStuff::BaseProjectItem]
287
287
  #
288
- def add_item(issue:, project_number:)
289
- project_number = resolve_default_project_number(project_number)
288
+ def add_item!(issue:, project_number:)
289
+ project_number = resolve_default_project_number!(project_number)
290
290
  client = PlanMyStuff.client
291
291
  org = PlanMyStuff.configuration.organization
292
292
 
@@ -329,8 +329,8 @@ module PlanMyStuff
329
329
  #
330
330
  # @return [PlanMyStuff::BaseProjectItem]
331
331
  #
332
- def add_draft_item(title:, body:, project_number:)
333
- project_number = resolve_default_project_number(project_number)
332
+ def add_draft_item!(title:, body:, project_number:)
333
+ project_number = resolve_default_project_number!(project_number)
334
334
  org = PlanMyStuff.configuration.organization
335
335
  project_id = resolve_project_id(org, project_number)
336
336
 
@@ -363,23 +363,25 @@ module PlanMyStuff
363
363
  )
364
364
  end
365
365
 
366
- # @see PlanMyStuff::Project.resolve_default_project_number
367
- def resolve_default_project_number(project_number)
368
- PlanMyStuff::Project.resolve_default_project_number(project_number)
366
+ # @see PlanMyStuff::Project.resolve_default_project_number!
367
+ def resolve_default_project_number!(project_number)
368
+ PlanMyStuff::Project.resolve_default_project_number!(project_number)
369
369
  end
370
370
 
371
- # Finds a text field by name on a project. Raises ArgumentError if
372
- # the field is not found or is a single-select field.
371
+ # Finds a text field by name on a project. Raises ArgumentError if the field is not found or is a single-select
372
+ # field.
373
+ #
374
+ # @raise [ArgumentError] if the field is not found or is a single-select field
373
375
  #
374
376
  # @param project [PlanMyStuff::BaseProject]
375
377
  # @param field_name [String]
376
378
  #
377
379
  # @return [Hash]
378
380
  #
379
- def resolve_text_field(project, field_name)
381
+ def resolve_text_field!(project, field_name)
380
382
  field = project.fields.find { |f| f[:name] == field_name && f[:options].nil? }
381
383
 
382
- unless field
384
+ if field.nil?
383
385
  available = project.fields.select { |f| f[:options].nil? }.pluck(:name).join(', ')
384
386
  raise(ArgumentError, "Unknown text field '#{field_name}'. Available: #{available}")
385
387
  end
@@ -387,15 +389,17 @@ module PlanMyStuff
387
389
  field
388
390
  end
389
391
 
392
+ # @raise [ArgumentError] if the field is not found or is not a single-select field
393
+ #
390
394
  # @param project [PlanMyStuff::BaseProject]
391
395
  # @param field_name [String]
392
396
  #
393
397
  # @return [Hash]
394
398
  #
395
- def resolve_single_select_field(project, field_name)
399
+ def resolve_single_select_field!(project, field_name)
396
400
  field = project.fields.find { |f| f[:name] == field_name && f[:options].present? }
397
401
 
398
- unless field
402
+ if field.nil?
399
403
  available = project.fields.select { |f| f[:options].present? }.pluck(:name).join(', ')
400
404
  raise(ArgumentError, "Unknown single-select field '#{field_name}'. Available: #{available}")
401
405
  end
@@ -405,15 +409,17 @@ module PlanMyStuff
405
409
 
406
410
  # Resolves a status name to its option ID.
407
411
  #
412
+ # @raise [ArgumentError] if +status_name+ is not a valid option for +status_field+
413
+ #
408
414
  # @param status_field [Hash]
409
415
  # @param status_name [String]
410
416
  #
411
417
  # @return [String]
412
418
  #
413
- def resolve_status_option_id(status_field, status_name)
419
+ def resolve_status_option_id!(status_field, status_name)
414
420
  option = status_field[:options].find { |o| o[:name] == status_name }
415
421
 
416
- unless option
422
+ if option.nil?
417
423
  available = status_field[:options].pluck(:name).join(', ')
418
424
  raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
419
425
  end
@@ -446,7 +452,7 @@ module PlanMyStuff
446
452
  #
447
453
  def move_to!(status, user: nil)
448
454
  previous_status = self.status
449
- result = self.class.move_item(
455
+ result = self.class.move_item!(
450
456
  project_number: project.number,
451
457
  item_id: id,
452
458
  status: status,
@@ -495,20 +501,17 @@ module PlanMyStuff
495
501
  )
496
502
  end
497
503
 
498
- # Deletes this item from its parent project. Marks the in-memory
499
- # instance as destroyed so +destroyed?+ returns true and +persisted?+
500
- # returns false.
504
+ # Deletes this item from its parent project. Marks the in-memory instance as destroyed so +destroyed?+ returns
505
+ # true and +persisted?+ returns false.
501
506
  #
502
507
  # No-op if the instance is already destroyed.
503
508
  #
504
509
  # @return [String, nil] the deleted item ID, or nil if already destroyed
505
510
  #
506
- # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
507
- #
508
511
  def destroy!(user: nil)
509
512
  return if destroyed?
510
513
 
511
- deleted_id = self.class.delete_item(
514
+ deleted_id = self.class.delete_item!(
512
515
  project_number: project.number,
513
516
  item_id: id,
514
517
  )
@@ -518,7 +521,8 @@ module PlanMyStuff
518
521
  deleted_id
519
522
  end
520
523
 
521
- # Assigns users to this item on its parent project.
524
+ # Assigns users to this item on its parent project. For draft items the underlying call uses the draft-issue
525
+ # assign mutation; for issue-backed items it delegates to +Issue.update!+ with +assignees:+.
522
526
  #
523
527
  # @param assignees [String, Array<String>] GitHub username(s)
524
528
  #
@@ -527,7 +531,7 @@ module PlanMyStuff
527
531
  def assign!(assignees, user: nil)
528
532
  assignee_list = Array.wrap(assignees)
529
533
 
530
- self.class.assign(
534
+ self.class.assign!(
531
535
  number: number,
532
536
  content_node_id: content_node_id,
533
537
  assignees: assignee_list,
@@ -543,8 +547,8 @@ module PlanMyStuff
543
547
  )
544
548
  end
545
549
 
546
- # Serializes the project item to a JSON-safe hash, excluding the back-reference
547
- # to the parent project to prevent recursive serialization cycles.
550
+ # Serializes the project item to a JSON-safe hash, excluding the back-reference to the parent project to prevent
551
+ # recursive serialization cycles.
548
552
  #
549
553
  # @return [Hash]
550
554
  #
@@ -573,7 +577,7 @@ module PlanMyStuff
573
577
  return super if @issue_assigned
574
578
  return if draft?
575
579
 
576
- self.issue = PMS::Issue.find(number, repo: repo)
580
+ self.issue = PlanMyStuff::Issue.find(number, repo: repo)
577
581
  super
578
582
  end
579
583
  end
@@ -4,7 +4,7 @@ module PlanMyStuff
4
4
  # Shared base for project-style metadata (regular and testing). Holds the
5
5
  # +kind+ dispatch field and serialization hook. Not instantiated directly;
6
6
  # use +ProjectMetadata+ or +TestingProjectMetadata+.
7
- class BaseProjectMetadata < BaseMetadata
7
+ class BaseProjectMetadata < PlanMyStuff::BaseMetadata
8
8
  # @return [String, nil] dispatch key: "project" or "testing"
9
9
  attr_accessor :kind
10
10
 
@@ -4,25 +4,63 @@ require 'active_support/core_ext/hash/keys'
4
4
  require 'octokit'
5
5
 
6
6
  module PlanMyStuff
7
- # Infrastructure wrapper around Octokit. Handles auth, error normalization,
8
- # and repo resolution. Domain modules (Issues, Projects, etc.) use this
9
- # internally via PlanMyStuff.client.
7
+ # Infrastructure wrapper around Octokit. Handles auth, error normalization, and repo resolution. Domain modules
8
+ # (Issues, Projects, etc.) use this internally via PlanMyStuff.client.
10
9
  class Client
11
10
  # @return [Octokit::Client]
12
11
  attr_reader :octokit
13
12
 
14
- # Returns the Faraday response from the most recent Octokit call.
15
- # Useful for reading headers (e.g. `ETag`, `X-RateLimit-Remaining`)
16
- # that aren't included in the parsed response body.
13
+ # Returns the Faraday response from the most recent Octokit call. Useful for reading headers (e.g. `ETag`,
14
+ # `X-RateLimit-Remaining`) that aren't included in the parsed response body.
17
15
  #
18
16
  # @return [Faraday::Response, nil]
19
17
  delegate :last_response, to: :octokit
20
18
 
19
+ class << self
20
+ # Activates trace mode: every +rest+ and +graphql+ call is recorded into +traced_requests+ until
21
+ # +exit_trace_mode!+ is called. Useful for debugging which GitHub calls a code path makes.
22
+ #
23
+ # @return [void]
24
+ #
25
+ def trace_mode!
26
+ @trace_mode = true
27
+ @traced_requests = []
28
+ end
29
+
30
+ # Disables trace mode and clears any recorded requests.
31
+ #
32
+ # @return [void]
33
+ #
34
+ def exit_trace_mode!
35
+ @trace_mode = false
36
+ @traced_requests = []
37
+ end
38
+
39
+ # @return [Boolean]
40
+ def trace_mode?
41
+ @trace_mode == true
42
+ end
43
+
44
+ # @return [Array<Hash>]
45
+ def traced_requests
46
+ @traced_requests ||= []
47
+ end
48
+ end
49
+
50
+ # @raise [PlanMyStuff::ConfigurationError] if +import_access_token+ is blank when +importing: true+
51
+ #
21
52
  # @return [Client]
22
- def initialize
53
+ #
54
+ def initialize(importing: false)
23
55
  PlanMyStuff.configuration.validate!
24
56
 
25
- @octokit = Octokit::Client.new(access_token: PlanMyStuff.configuration.access_token)
57
+ access_token = importing ? PlanMyStuff.configuration.import_access_token : PlanMyStuff.configuration.access_token
58
+
59
+ if importing && access_token.blank?
60
+ raise(PlanMyStuff::ConfigurationError, 'Import access token is required for import client but not configured')
61
+ end
62
+
63
+ @octokit = Octokit::Client.new(access_token: access_token)
26
64
  end
27
65
 
28
66
  # Delegates a REST API call to Octokit, normalizing errors.
@@ -33,16 +71,10 @@ module PlanMyStuff
33
71
  #
34
72
  # @return [Object] Octokit response
35
73
  #
36
- def rest(method, *, **kwargs, &)
37
- if kwargs.empty?
38
- octokit.public_send(method, *, &)
39
- else
40
- octokit.public_send(method, *, **kwargs, &)
74
+ def rest(method, *args, **kwargs, &block)
75
+ trace!(:rest, method: method, args: args, kwargs: kwargs) do
76
+ execute_rest!(method, *args, **kwargs, &block)
41
77
  end
42
- rescue Octokit::TooManyRequests => e
43
- raise_rate_limit_error(e)
44
- rescue Octokit::ClientError, Octokit::ServerError => e
45
- raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
46
78
  end
47
79
 
48
80
  # Executes a GraphQL query against GitHub's /graphql endpoint.
@@ -53,26 +85,9 @@ module PlanMyStuff
53
85
  # @return [Hash] parsed response data
54
86
  #
55
87
  def graphql(query, variables: {})
56
- payload = { query: query }
57
- payload[:variables] = variables unless variables.empty?
58
-
59
- response = octokit.post('/graphql', payload.to_json)
60
- data =
61
- if response.is_a?(Hash)
62
- response
63
- else
64
- (response.respond_to?(:to_h) ? response.to_h : response)
65
- end
66
-
67
- data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
68
-
69
- check_graphql_errors!(data)
70
-
71
- data[:data]
72
- rescue Octokit::TooManyRequests => e
73
- raise_rate_limit_error(e)
74
- rescue Octokit::ClientError, Octokit::ServerError => e
75
- raise(APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
88
+ trace!(:graphql, query: query, variables: variables) do
89
+ execute_graphql!(query, variables)
90
+ end
76
91
  end
77
92
 
78
93
  # Resolves a repo param to a full "Org/Repo" string.
@@ -81,17 +96,90 @@ module PlanMyStuff
81
96
  #
82
97
  # @return [String] full repo path (e.g. "BrandsInsurance/Element")
83
98
  #
84
- # @raise [ArgumentError] if repo cannot be resolved
85
- #
86
- def resolve_repo(repo = nil)
87
- PlanMyStuff::Repo.resolve(repo).full_name
99
+ def resolve_repo!(repo = nil)
100
+ PlanMyStuff::Repo.resolve!(repo).full_name
88
101
  end
89
102
 
90
103
  private
91
104
 
92
- # @param data [Hash] parsed GraphQL response
105
+ # @raise [PlanMyStuff::APIError] if Octokit call fails with client or server error
106
+ #
107
+ # @return [Object]
108
+ #
109
+ def execute_rest!(method, *, **kwargs, &)
110
+ if kwargs.empty?
111
+ octokit.public_send(method, *, &)
112
+ else
113
+ octokit.public_send(method, *, **kwargs, &)
114
+ end
115
+ rescue Octokit::TooManyRequests => e
116
+ raise_rate_limit_error!(e)
117
+ rescue Octokit::ClientError, Octokit::ServerError => e
118
+ raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
119
+ end
120
+
121
+ # @raise [PlanMyStuff::APIError] if Octokit call fails with client or server error
122
+ #
123
+ # @return [Hash]
124
+ #
125
+ def execute_graphql!(query, variables)
126
+ payload = { query: query }
127
+ payload[:variables] = variables unless variables.empty?
128
+
129
+ response = octokit.post('/graphql', payload.to_json)
130
+ data =
131
+ if response.is_a?(Hash)
132
+ response
133
+ else
134
+ (response.respond_to?(:to_h) ? response.to_h : response)
135
+ end
136
+
137
+ data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
138
+
139
+ check_graphql_errors!(data)
140
+
141
+ data[:data]
142
+ rescue Octokit::TooManyRequests => e
143
+ raise_rate_limit_error!(e)
144
+ rescue Octokit::ClientError, Octokit::ServerError => e
145
+ raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
146
+ end
147
+
148
+ # Records the wrapped call into +Client.traced_requests+ when trace mode is on; otherwise just yields.
93
149
  #
94
- # @raise [GraphQLError] if response contains errors
150
+ # @param kind [Symbol] :rest or :graphql
151
+ # @param details [Hash] call-specific details (method/args/query/variables)
152
+ #
153
+ # @return [Object]
154
+ #
155
+ def trace!(kind, **details)
156
+ return yield unless self.class.trace_mode?
157
+
158
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ begin
160
+ result = yield
161
+ record_trace(kind, start, details)
162
+ result
163
+ rescue => e
164
+ record_trace(kind, start, details, error: e)
165
+ raise
166
+ end
167
+ end
168
+
169
+ # @return [void]
170
+ def record_trace(kind, start, details, error: nil)
171
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1_000).round(2)
172
+ entry = { kind: kind, duration_ms: duration_ms, status: octokit.last_response&.status, **details }
173
+ if error
174
+ entry[:error] = error.class.name
175
+ entry[:error_message] = error.message
176
+ end
177
+ self.class.traced_requests << entry
178
+ end
179
+
180
+ # @raise [PlanMyStuff::GraphQLError] if response contains errors
181
+ #
182
+ # @param data [Hash] parsed GraphQL response
95
183
  #
96
184
  # @return [void]
97
185
  #
@@ -100,16 +188,16 @@ module PlanMyStuff
100
188
  return if errors.blank?
101
189
 
102
190
  messages = errors.filter_map { |e| e[:message] }
103
- raise(GraphQLError.new(messages.join('; '), errors: errors))
191
+ raise(PlanMyStuff::GraphQLError.new(messages.join('; '), errors: errors))
104
192
  end
105
193
 
106
- # @param exception [Octokit::TooManyRequests]
194
+ # @raise [PlanMyStuff::RateLimitError] if GitHub has been rate limited
107
195
  #
108
- # @raise [RateLimitError]
196
+ # @param exception [Octokit::TooManyRequests]
109
197
  #
110
- def raise_rate_limit_error(exception)
198
+ def raise_rate_limit_error!(exception)
111
199
  retry_after = parse_retry_after(exception)
112
- raise(RateLimitError.new(exception.message, retry_after: retry_after))
200
+ raise(PlanMyStuff::RateLimitError.new(exception.message, retry_after: retry_after))
113
201
  end
114
202
 
115
203
  # @param exception [Octokit::TooManyRequests]