plan_my_stuff 0.7.0 → 0.9.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/CONFIGURATION.md +351 -0
  4. data/README.md +100 -103
  5. data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
  11. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
  12. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
  13. data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
  14. data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
  15. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
  16. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
  17. data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
  18. data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
  19. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
  20. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
  21. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
  22. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
  23. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
  24. data/app/jobs/plan_my_stuff/application_job.rb +2 -3
  25. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
  26. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  27. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
  28. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  29. data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
  30. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  31. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
  32. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
  33. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
  34. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
  35. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
  36. data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
  37. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  38. data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
  39. data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
  40. data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
  41. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
  42. data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
  43. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
  44. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
  45. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
  46. data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
  47. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
  48. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
  50. data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
  51. data/config/routes.rb +2 -2
  52. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +52 -14
  53. data/lib/plan_my_stuff/approval.rb +12 -4
  54. data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
  55. data/lib/plan_my_stuff/base_metadata.rb +4 -15
  56. data/lib/plan_my_stuff/base_project.rb +68 -55
  57. data/lib/plan_my_stuff/base_project_item.rb +62 -57
  58. data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
  59. data/lib/plan_my_stuff/client.rb +136 -48
  60. data/lib/plan_my_stuff/comment.rb +59 -57
  61. data/lib/plan_my_stuff/comment_metadata.rb +1 -1
  62. data/lib/plan_my_stuff/configuration.rb +93 -93
  63. data/lib/plan_my_stuff/errors.rb +10 -10
  64. data/lib/plan_my_stuff/graphql/queries.rb +1 -1
  65. data/lib/plan_my_stuff/issue.rb +471 -333
  66. data/lib/plan_my_stuff/issue_metadata.rb +10 -10
  67. data/lib/plan_my_stuff/label.rb +34 -18
  68. data/lib/plan_my_stuff/link.rb +15 -15
  69. data/lib/plan_my_stuff/markdown.rb +12 -6
  70. data/lib/plan_my_stuff/metadata_parser.rb +3 -1
  71. data/lib/plan_my_stuff/notifications.rb +1 -1
  72. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
  73. data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
  74. data/lib/plan_my_stuff/pipeline.rb +61 -83
  75. data/lib/plan_my_stuff/project.rb +4 -4
  76. data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
  77. data/lib/plan_my_stuff/project_metadata.rb +1 -1
  78. data/lib/plan_my_stuff/reminders/closer.rb +1 -1
  79. data/lib/plan_my_stuff/reminders/fire.rb +3 -3
  80. data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
  81. data/lib/plan_my_stuff/repo.rb +12 -6
  82. data/lib/plan_my_stuff/test_helpers.rb +11 -11
  83. data/lib/plan_my_stuff/testing_project.rb +12 -11
  84. data/lib/plan_my_stuff/testing_project_item.rb +11 -9
  85. data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
  86. data/lib/plan_my_stuff/version.rb +1 -1
  87. data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
  88. data/lib/plan_my_stuff.rb +26 -2
  89. data/lib/tasks/plan_my_stuff.rake +33 -20
  90. metadata +4 -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")
@@ -74,6 +73,7 @@ module PlanMyStuff
74
73
  repo: item_hash[:repo],
75
74
  state: item_hash[:state],
76
75
  status: item_hash[:status],
76
+ updated_at: item_hash[:updated_at],
77
77
  field_values: item_hash[:field_values] || {},
78
78
  project: project,
79
79
  )
@@ -101,9 +101,9 @@ module PlanMyStuff
101
101
  def create!(issue_or_title, draft: false, body: nil, project_number: nil, user: nil)
102
102
  item =
103
103
  if draft
104
- add_draft_item(title: issue_or_title, body: body, project_number: project_number)
104
+ add_draft_item!(title: issue_or_title, body: body, project_number: project_number)
105
105
  else
106
- add_item(issue: issue_or_title, project_number: project_number)
106
+ add_item!(issue: issue_or_title, project_number: project_number)
107
107
  end
108
108
 
109
109
  PlanMyStuff::Notifications.instrument('project_item.added', item, user: user)
@@ -118,12 +118,12 @@ module PlanMyStuff
118
118
  #
119
119
  # @return [Hash] the updated item
120
120
  #
121
- def move_item(item_id:, status:, project_number: nil)
122
- project_number = resolve_default_project_number(project_number)
121
+ def move_item!(item_id:, status:, project_number: nil)
122
+ project_number = resolve_default_project_number!(project_number)
123
123
  project = BaseProject.find(project_number)
124
124
 
125
125
  status_field = project.status_field
126
- option_id = resolve_status_option_id(status_field, status)
126
+ option_id = resolve_status_option_id!(status_field, status)
127
127
 
128
128
  PlanMyStuff.client.graphql(
129
129
  PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
@@ -146,11 +146,11 @@ module PlanMyStuff
146
146
  # @return [Hash] mutation result
147
147
  #
148
148
  def update_single_select_field!(item_id:, field_name:, value:, project_number: nil)
149
- project_number = resolve_default_project_number(project_number)
149
+ project_number = resolve_default_project_number!(project_number)
150
150
  project = BaseProject.find(project_number)
151
151
 
152
- field = resolve_single_select_field(project, field_name)
153
- option_id = resolve_status_option_id(field, value)
152
+ field = resolve_single_select_field!(project, field_name)
153
+ option_id = resolve_status_option_id!(field, value)
154
154
 
155
155
  PlanMyStuff.client.graphql(
156
156
  PlanMyStuff::GraphQL::Queries::UPDATE_SINGLE_SELECT_FIELD,
@@ -173,10 +173,10 @@ module PlanMyStuff
173
173
  # @return [Hash] mutation result
174
174
  #
175
175
  def update_field!(item_id:, field_name:, value:, project_number: nil)
176
- project_number = resolve_default_project_number(project_number)
176
+ project_number = resolve_default_project_number!(project_number)
177
177
  project = BaseProject.find(project_number)
178
178
 
179
- field = resolve_text_field(project, field_name)
179
+ field = resolve_text_field!(project, field_name)
180
180
 
181
181
  PlanMyStuff.client.graphql(
182
182
  PlanMyStuff::GraphQL::Queries::UPDATE_TEXT_FIELD,
@@ -199,10 +199,10 @@ module PlanMyStuff
199
199
  # @return [Hash] mutation result
200
200
  #
201
201
  def update_date_field!(item_id:, field_name:, date:, project_number: nil)
202
- project_number = resolve_default_project_number(project_number)
202
+ project_number = resolve_default_project_number!(project_number)
203
203
  project = BaseProject.find(project_number)
204
204
 
205
- field = resolve_text_field(project, field_name)
205
+ field = resolve_text_field!(project, field_name)
206
206
 
207
207
  PlanMyStuff.client.graphql(
208
208
  PlanMyStuff::GraphQL::Queries::UPDATE_DATE_FIELD,
@@ -215,18 +215,18 @@ module PlanMyStuff
215
215
  )
216
216
  end
217
217
 
218
- # Deletes a project item from its parent project. Returns the
219
- # +deletedItemId+ from the GraphQL response on success.
218
+ # Deletes a project item from its parent project. Returns the +deletedItemId+ from the GraphQL response on
219
+ # success.
220
+ #
221
+ # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
220
222
  #
221
223
  # @param item_id [String] project item ID (e.g. "PVTI_...")
222
224
  # @param project_number [Integer, nil] defaults to config.default_project_number
223
225
  #
224
226
  # @return [String] the deleted item ID echoed back by GitHub
225
227
  #
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)
228
+ def delete_item!(item_id:, project_number: nil)
229
+ project_number = resolve_default_project_number!(project_number)
230
230
  org = PlanMyStuff.configuration.organization
231
231
  project_id = resolve_project_id(org, project_number)
232
232
 
@@ -241,8 +241,9 @@ module PlanMyStuff
241
241
  deleted_id
242
242
  end
243
243
 
244
- # Assigns users to a project item.
245
- # Issues/PRs use REST via Issue.update!, drafts use GraphQL.
244
+ # Assigns users to a project item. Issues/PRs use REST via Issue.update!, drafts use GraphQL.
245
+ #
246
+ # @raise [PlanMyStuff::APIError] if a username cannot be resolved or the GraphQL mutation fails
246
247
  #
247
248
  # @param number [Integer, nil] issue number (nil for drafts)
248
249
  # @param content_node_id [String] node ID of the underlying content
@@ -252,7 +253,7 @@ module PlanMyStuff
252
253
  #
253
254
  # @return [void]
254
255
  #
255
- def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
256
+ def assign!(number:, content_node_id:, assignees:, draft: false, repo: nil)
256
257
  if draft
257
258
  client = PlanMyStuff.client
258
259
  user_ids = assignees.map do |assignee|
@@ -262,7 +263,7 @@ module PlanMyStuff
262
263
  )
263
264
  user_id = user_data.dig(:user, :id)
264
265
 
265
- raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
266
+ raise(PlanMyStuff::APIError, "GitHub user not found: #{assignee}") if user_id.nil?
266
267
 
267
268
  user_id
268
269
  end
@@ -272,7 +273,7 @@ module PlanMyStuff
272
273
  variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
273
274
  )
274
275
  else
275
- Issue.update!(number: number, repo: repo, assignees: assignees)
276
+ PlanMyStuff::Issue.update!(number: number, repo: repo, assignees: assignees)
276
277
  end
277
278
  end
278
279
 
@@ -285,8 +286,8 @@ module PlanMyStuff
285
286
  #
286
287
  # @return [PlanMyStuff::BaseProjectItem]
287
288
  #
288
- def add_item(issue:, project_number:)
289
- project_number = resolve_default_project_number(project_number)
289
+ def add_item!(issue:, project_number:)
290
+ project_number = resolve_default_project_number!(project_number)
290
291
  client = PlanMyStuff.client
291
292
  org = PlanMyStuff.configuration.organization
292
293
 
@@ -329,8 +330,8 @@ module PlanMyStuff
329
330
  #
330
331
  # @return [PlanMyStuff::BaseProjectItem]
331
332
  #
332
- def add_draft_item(title:, body:, project_number:)
333
- project_number = resolve_default_project_number(project_number)
333
+ def add_draft_item!(title:, body:, project_number:)
334
+ project_number = resolve_default_project_number!(project_number)
334
335
  org = PlanMyStuff.configuration.organization
335
336
  project_id = resolve_project_id(org, project_number)
336
337
 
@@ -363,23 +364,25 @@ module PlanMyStuff
363
364
  )
364
365
  end
365
366
 
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)
367
+ # @see PlanMyStuff::Project.resolve_default_project_number!
368
+ def resolve_default_project_number!(project_number)
369
+ PlanMyStuff::Project.resolve_default_project_number!(project_number)
369
370
  end
370
371
 
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.
372
+ # Finds a text field by name on a project. Raises ArgumentError if the field is not found or is a single-select
373
+ # field.
374
+ #
375
+ # @raise [ArgumentError] if the field is not found or is a single-select field
373
376
  #
374
377
  # @param project [PlanMyStuff::BaseProject]
375
378
  # @param field_name [String]
376
379
  #
377
380
  # @return [Hash]
378
381
  #
379
- def resolve_text_field(project, field_name)
382
+ def resolve_text_field!(project, field_name)
380
383
  field = project.fields.find { |f| f[:name] == field_name && f[:options].nil? }
381
384
 
382
- unless field
385
+ if field.nil?
383
386
  available = project.fields.select { |f| f[:options].nil? }.pluck(:name).join(', ')
384
387
  raise(ArgumentError, "Unknown text field '#{field_name}'. Available: #{available}")
385
388
  end
@@ -387,15 +390,17 @@ module PlanMyStuff
387
390
  field
388
391
  end
389
392
 
393
+ # @raise [ArgumentError] if the field is not found or is not a single-select field
394
+ #
390
395
  # @param project [PlanMyStuff::BaseProject]
391
396
  # @param field_name [String]
392
397
  #
393
398
  # @return [Hash]
394
399
  #
395
- def resolve_single_select_field(project, field_name)
400
+ def resolve_single_select_field!(project, field_name)
396
401
  field = project.fields.find { |f| f[:name] == field_name && f[:options].present? }
397
402
 
398
- unless field
403
+ if field.nil?
399
404
  available = project.fields.select { |f| f[:options].present? }.pluck(:name).join(', ')
400
405
  raise(ArgumentError, "Unknown single-select field '#{field_name}'. Available: #{available}")
401
406
  end
@@ -405,15 +410,17 @@ module PlanMyStuff
405
410
 
406
411
  # Resolves a status name to its option ID.
407
412
  #
413
+ # @raise [ArgumentError] if +status_name+ is not a valid option for +status_field+
414
+ #
408
415
  # @param status_field [Hash]
409
416
  # @param status_name [String]
410
417
  #
411
418
  # @return [String]
412
419
  #
413
- def resolve_status_option_id(status_field, status_name)
420
+ def resolve_status_option_id!(status_field, status_name)
414
421
  option = status_field[:options].find { |o| o[:name] == status_name }
415
422
 
416
- unless option
423
+ if option.nil?
417
424
  available = status_field[:options].pluck(:name).join(', ')
418
425
  raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
419
426
  end
@@ -446,7 +453,7 @@ module PlanMyStuff
446
453
  #
447
454
  def move_to!(status, user: nil)
448
455
  previous_status = self.status
449
- result = self.class.move_item(
456
+ result = self.class.move_item!(
450
457
  project_number: project.number,
451
458
  item_id: id,
452
459
  status: status,
@@ -495,20 +502,17 @@ module PlanMyStuff
495
502
  )
496
503
  end
497
504
 
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.
505
+ # Deletes this item from its parent project. Marks the in-memory instance as destroyed so +destroyed?+ returns
506
+ # true and +persisted?+ returns false.
501
507
  #
502
508
  # No-op if the instance is already destroyed.
503
509
  #
504
510
  # @return [String, nil] the deleted item ID, or nil if already destroyed
505
511
  #
506
- # @raise [PlanMyStuff::APIError] if the GraphQL mutation fails
507
- #
508
512
  def destroy!(user: nil)
509
513
  return if destroyed?
510
514
 
511
- deleted_id = self.class.delete_item(
515
+ deleted_id = self.class.delete_item!(
512
516
  project_number: project.number,
513
517
  item_id: id,
514
518
  )
@@ -518,7 +522,8 @@ module PlanMyStuff
518
522
  deleted_id
519
523
  end
520
524
 
521
- # Assigns users to this item on its parent project.
525
+ # Assigns users to this item on its parent project. For draft items the underlying call uses the draft-issue
526
+ # assign mutation; for issue-backed items it delegates to +Issue.update!+ with +assignees:+.
522
527
  #
523
528
  # @param assignees [String, Array<String>] GitHub username(s)
524
529
  #
@@ -527,7 +532,7 @@ module PlanMyStuff
527
532
  def assign!(assignees, user: nil)
528
533
  assignee_list = Array.wrap(assignees)
529
534
 
530
- self.class.assign(
535
+ self.class.assign!(
531
536
  number: number,
532
537
  content_node_id: content_node_id,
533
538
  assignees: assignee_list,
@@ -543,8 +548,8 @@ module PlanMyStuff
543
548
  )
544
549
  end
545
550
 
546
- # Serializes the project item to a JSON-safe hash, excluding the back-reference
547
- # to the parent project to prevent recursive serialization cycles.
551
+ # Serializes the project item to a JSON-safe hash, excluding the back-reference to the parent project to prevent
552
+ # recursive serialization cycles.
548
553
  #
549
554
  # @return [Hash]
550
555
  #
@@ -573,7 +578,7 @@ module PlanMyStuff
573
578
  return super if @issue_assigned
574
579
  return if draft?
575
580
 
576
- self.issue = PMS::Issue.find(number, repo: repo)
581
+ self.issue = PlanMyStuff::Issue.find(number, repo: repo)
577
582
  super
578
583
  end
579
584
  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]