plan_my_stuff 0.6.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 +41 -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 +56 -3
  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 +501 -322
  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
@@ -100,19 +100,19 @@ module PlanMyStuff
100
100
  def pending_approvers
101
101
  @issue.pending_approvals.filter_map do |approval|
102
102
  PlanMyStuff::UserResolver.resolve(approval.user_id)
103
- rescue
103
+ rescue ActiveRecord::RecordNotFound
104
104
  next
105
105
  end
106
106
  end
107
107
 
108
108
  # @return [Array<Integer>]
109
109
  def effective_reminder_days
110
- @issue.metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
110
+ (@issue.metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days).sort
111
111
  end
112
112
 
113
113
  # Time of the next milestone past +now+, or +nil+ when the last
114
114
  # milestone has passed. Returned as a native +Time+ (not ISO
115
- # string) so +IssueMetadata#to_h+'s +format_time+ serializes it
115
+ # string) so +IssueMetadata#to_h+'s +PlanMyStuff.format_time+ serializes it
116
116
  # cleanly.
117
117
  #
118
118
  # @return [Time, nil]
@@ -23,10 +23,10 @@ module PlanMyStuff
23
23
  return unless PlanMyStuff.configuration.reminders_enabled
24
24
 
25
25
  candidates.each do |issue|
26
- if Closer.should_close?(issue, now: @now)
27
- Closer.new(issue, now: @now).call
28
- elsif Fire.ready?(issue, now: @now)
29
- Fire.new(issue, now: @now).call
26
+ if PlanMyStuff::Reminders::Closer.should_close?(issue, now: @now)
27
+ PlanMyStuff::Reminders::Closer.new(issue, now: @now).call
28
+ elsif PlanMyStuff::Reminders::Fire.ready?(issue, now: @now)
29
+ PlanMyStuff::Reminders::Fire.new(issue, now: @now).call
30
30
  end
31
31
  end
32
32
  end
@@ -16,11 +16,15 @@ module PlanMyStuff
16
16
  class << self
17
17
  # Builds a Repo instance from a Symbol key, full name String, or nil (default).
18
18
  #
19
+ # @raise [PlanMyStuff::ConfigurationError] if repo is not provided and cannot be resolved from config
20
+ # @raise [ArgumentError] if repo cannot be resolved
21
+ # @raise [ArgumentError] if repo is invalid format
22
+ #
19
23
  # @param repo [Symbol, String, PlanMyStuff::Repo, nil]
20
24
  #
21
25
  # @return [PlanMyStuff::Repo]
22
26
  #
23
- def resolve(repo = nil)
27
+ def resolve!(repo = nil)
24
28
  return repo if repo.is_a?(PlanMyStuff::Repo)
25
29
 
26
30
  repo ||= PlanMyStuff.configuration.default_repo
@@ -38,10 +42,10 @@ module PlanMyStuff
38
42
  full_name = PlanMyStuff.configuration.repos[repo]
39
43
  raise(ArgumentError, "Unknown repo key: #{repo.inspect}") if full_name.nil?
40
44
 
41
- from_full_name(full_name, key: repo)
45
+ from_full_name!(full_name, key: repo)
42
46
  when String
43
47
  key = PlanMyStuff.configuration.repos.key(repo)
44
- from_full_name(repo, key: key)
48
+ from_full_name!(repo, key: key)
45
49
  else
46
50
  raise(ArgumentError, "Cannot resolve repo: #{repo.inspect}")
47
51
  end
@@ -49,12 +53,14 @@ module PlanMyStuff
49
53
 
50
54
  private
51
55
 
56
+ # @raise [ArgumentError] if full_name is not in "Org/Repo" format
57
+ #
52
58
  # @param full_name [String] e.g. "YourOrgName/MyRepository"
53
59
  # @param key [Symbol, nil]
54
60
  #
55
61
  # @return [PlanMyStuff::Repo]
56
62
  #
57
- def from_full_name(full_name, key: nil)
63
+ def from_full_name!(full_name, key: nil)
58
64
  org, name = full_name.split('/', 2)
59
65
 
60
66
  raise(ArgumentError, "Invalid repo full_name: #{full_name.inspect}") if name.nil?
@@ -81,8 +87,8 @@ module PlanMyStuff
81
87
  # @see #full_name
82
88
  alias to_s full_name
83
89
 
84
- # Enables implicit string coercion so Repo instances behave as
85
- # strings when passed to Octokit or compared with String#==.
90
+ # Enables implicit string coercion so Repo instances behave as strings when passed to Octokit or compared with
91
+ # String#==.
86
92
  #
87
93
  # @see #full_name
88
94
  alias to_str full_name
@@ -306,7 +306,7 @@ module PlanMyStuff
306
306
 
307
307
  expect(match).to(
308
308
  be_truthy,
309
- "Expected PMS #{description} with #{filters.inspect}, " \
309
+ "Expected PlanMyStuff #{description} with #{filters.inspect}, " \
310
310
  "but recorded actions were: #{format_actions(type)}",
311
311
  )
312
312
  end
@@ -335,7 +335,7 @@ module PlanMyStuff
335
335
  # @return [void]
336
336
  #
337
337
  def test_mode!
338
- TestHelpers.recorded_actions = []
338
+ PlanMyStuff::TestHelpers.recorded_actions = []
339
339
  return if @_test_mode
340
340
 
341
341
  @_test_mode_originals = {}
@@ -367,7 +367,7 @@ module PlanMyStuff
367
367
 
368
368
  @_test_mode_originals = nil
369
369
  @_test_mode = false
370
- TestHelpers.recorded_actions = []
370
+ PlanMyStuff::TestHelpers.recorded_actions = []
371
371
  end
372
372
 
373
373
  private
@@ -400,7 +400,7 @@ module PlanMyStuff
400
400
  def stub_issue_class_methods!
401
401
  issue_mod = PlanMyStuff::Issue
402
402
  %i[create! find list update!].each { |m| save_original(issue_mod, m) }
403
- %i[add_viewers remove_viewers].each { |m| save_instance_original(issue_mod, m) }
403
+ %i[add_viewers! remove_viewers!].each { |m| save_instance_original(issue_mod, m) }
404
404
 
405
405
  issue_mod.define_singleton_method(:create!) do |**params|
406
406
  PlanMyStuff::TestHelpers.recorded_actions << {
@@ -415,7 +415,7 @@ module PlanMyStuff
415
415
 
416
416
  resolved_repo =
417
417
  if params[:repo]
418
- PlanMyStuff::Repo.resolve(params[:repo]).full_name
418
+ PlanMyStuff::Repo.resolve!(params[:repo]).full_name
419
419
  else
420
420
  'TestOrg/TestRepo'
421
421
  end
@@ -434,7 +434,7 @@ module PlanMyStuff
434
434
  params: { number: number, repo: repo },
435
435
  }
436
436
 
437
- resolved_repo = repo ? PlanMyStuff::Repo.resolve(repo).full_name : 'TestOrg/TestRepo'
437
+ resolved_repo = repo ? PlanMyStuff::Repo.resolve!(repo).full_name : 'TestOrg/TestRepo'
438
438
  PlanMyStuff::TestHelpers.build_issue(number: number, repo: resolved_repo)
439
439
  end
440
440
 
@@ -456,7 +456,7 @@ module PlanMyStuff
456
456
  nil
457
457
  end
458
458
 
459
- issue_mod.define_method(:add_viewers) do |user_ids:, user: nil|
459
+ issue_mod.define_method(:add_viewers!) do |user_ids:, user: nil|
460
460
  PlanMyStuff::TestHelpers.recorded_actions << {
461
461
  type: :viewers_added,
462
462
  params: { number: number, repo: repo&.to_s, user_ids: Array.wrap(user_ids), user: user },
@@ -465,7 +465,7 @@ module PlanMyStuff
465
465
  nil
466
466
  end
467
467
 
468
- issue_mod.define_method(:remove_viewers) do |user_ids:, user: nil|
468
+ issue_mod.define_method(:remove_viewers!) do |user_ids:, user: nil|
469
469
  PlanMyStuff::TestHelpers.recorded_actions << {
470
470
  type: :viewers_removed,
471
471
  params: { number: number, repo: repo&.to_s, user_ids: Array.wrap(user_ids), user: user },
@@ -548,9 +548,9 @@ module PlanMyStuff
548
548
  # @return [void]
549
549
  def stub_project_item_class_methods!
550
550
  item_mod = PlanMyStuff::ProjectItem
551
- %i[move_item create! assign].each { |m| save_original(item_mod, m) }
551
+ %i[move_item! create! assign!].each { |m| save_original(item_mod, m) }
552
552
 
553
- item_mod.define_singleton_method(:move_item) do |**params|
553
+ item_mod.define_singleton_method(:move_item!) do |**params|
554
554
  PlanMyStuff::TestHelpers.recorded_actions << {
555
555
  type: :item_moved,
556
556
  params: {
@@ -589,7 +589,7 @@ module PlanMyStuff
589
589
  )
590
590
  end
591
591
 
592
- item_mod.define_singleton_method(:assign) do |**params|
592
+ item_mod.define_singleton_method(:assign!) do |**params|
593
593
  PlanMyStuff::TestHelpers.recorded_actions << {
594
594
  type: :item_assigned,
595
595
  params: {
@@ -17,7 +17,7 @@ module PlanMyStuff
17
17
  # "Test Status" is intentionally a separate field from GitHub's built-in
18
18
  # "Status" (which BaseProject and Pipeline hardcode). Using a parallel
19
19
  # field lets the QA sign-off flow own its own options (Passed/Failed)
20
- # without colliding with the board's default Status column a testing
20
+ # without colliding with the board's default Status column - a testing
21
21
  # project pointed at a pipeline-tracked repo won't overwrite either.
22
22
  BOOTSTRAP_FIELDS = [
23
23
  { name: 'Test Status', data_type: 'SINGLE_SELECT', options: ['Todo', 'In Progress', 'Passed', 'Failed'] },
@@ -100,12 +100,12 @@ module PlanMyStuff
100
100
  # Finds a testing project by number. Raises if the project exists but is
101
101
  # not a testing project.
102
102
  #
103
+ # @raise [ArgumentError] if the project is not a testing project
104
+ #
103
105
  # @param number [Integer]
104
106
  # @param paginate [Symbol] :auto (default) or :cursor
105
107
  # @param cursor [String, nil]
106
108
  #
107
- # @raise [ArgumentError] if the project is not a testing project
108
- #
109
109
  # @return [PlanMyStuff::TestingProject]
110
110
  #
111
111
  def find(number, paginate: :auto, cursor: nil)
@@ -142,7 +142,7 @@ module PlanMyStuff
142
142
  def create_from_template!(title:, description:, readme:, project_metadata:, template_number:)
143
143
  project = clone!(source_number: template_number, title: title)
144
144
 
145
- serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
145
+ serialized_readme = PlanMyStuff::MetadataParser.serialize!(project_metadata.to_h, readme)
146
146
  update_input = { projectId: project.id, readme: serialized_readme }
147
147
  update_input[:shortDescription] = description if description.present?
148
148
 
@@ -177,7 +177,7 @@ module PlanMyStuff
177
177
  project_id = new_project[:id]
178
178
  project_number = new_project[:number]
179
179
 
180
- serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
180
+ serialized_readme = PlanMyStuff::MetadataParser.serialize!(project_metadata.to_h, readme)
181
181
  update_input = { projectId: project_id, readme: serialized_readme }
182
182
  update_input[:shortDescription] = description if description.present?
183
183
 
@@ -199,8 +199,9 @@ module PlanMyStuff
199
199
  # @return [void]
200
200
  #
201
201
  def bootstrap_fields!(project_id, project_number)
202
- current = find(project_number)
203
- existing_names = current.fields.pluck(:name)
202
+ org = PlanMyStuff.configuration.organization
203
+ page = fetch_project_page(org, project_number, nil)
204
+ existing_names = (page[:raw].dig(:fields, :nodes) || []).pluck(:name)
204
205
 
205
206
  BOOTSTRAP_FIELDS.each do |field_def|
206
207
  next if existing_names.include?(field_def[:name])
@@ -246,14 +247,14 @@ module PlanMyStuff
246
247
 
247
248
  # Returns the Test Status single-select field definition.
248
249
  #
249
- # @return [Hash] with :id and :options keys
250
- #
251
250
  # @raise [PlanMyStuff::APIError] if no Test Status field exists
252
251
  #
253
- def status_field
252
+ # @return [Hash] with :id and :options keys
253
+ #
254
+ def status_field!
254
255
  field = fields.find { |f| f[:name] == 'Test Status' && f[:options] }
255
256
 
256
- raise(APIError, "No 'Test Status' field found on project ##{number}") unless field
257
+ raise(PlanMyStuff::APIError, "No 'Test Status' field found on project ##{number}") unless field
257
258
 
258
259
  field
259
260
  end
@@ -25,7 +25,7 @@ module PlanMyStuff
25
25
  #
26
26
  def create!(issue_or_title, draft: false, body: nil, project_number: nil, user: nil)
27
27
  item = super
28
- move_item(project_number: item.project.number, item_id: item.id, status: DEFAULT_STATUS)
28
+ move_item!(project_number: item.project.number, item_id: item.id, status: DEFAULT_STATUS)
29
29
  update_single_select_field!(
30
30
  project_number: item.project.number,
31
31
  item_id: item.id,
@@ -103,7 +103,7 @@ module PlanMyStuff
103
103
  # @return [Hash] mutation result
104
104
  #
105
105
  def update_passed_at!(time)
106
- update_field!('Passed At', time.utc.iso8601)
106
+ update_field!('Passed At', PlanMyStuff.format_time(time))
107
107
  end
108
108
 
109
109
  # Updates the Deadline Miss Reason field on this testing project item.
@@ -124,14 +124,16 @@ module PlanMyStuff
124
124
  #
125
125
  # No-op when the user has already signed off on this item.
126
126
  #
127
+ # @raise [PlanMyStuff::ValidationError] when user is blank
128
+ #
127
129
  # @param user [Object] PMS user object of the tester signing off
128
130
  #
129
131
  # @return [void]
130
132
  #
131
133
  def mark_passed!(user)
132
- raise(PMS::ValidationError, 'No user configured for sign-off.') if user.blank?
134
+ raise(PlanMyStuff::ValidationError, 'No user configured for sign-off.') if user.blank?
133
135
 
134
- user_id = PMS::UserResolver.user_id(user).to_s
136
+ user_id = PlanMyStuff::UserResolver.user_id(user).to_s
135
137
  current = user_ids_from_field('Passed By')
136
138
  return if current.include?(user_id)
137
139
 
@@ -158,20 +160,20 @@ module PlanMyStuff
158
160
  # Records +user_id+ as having failed this item, writes +result_notes+,
159
161
  # and moves the item to the Failed status.
160
162
  #
163
+ # @raise [PlanMyStuff::ValidationError] if result_notes is blank
164
+ #
161
165
  # @param user [Object] PMS user object of the tester failing the item
162
166
  # @param result_notes [String] required explanation of the failure
163
167
  #
164
- # @raise [PlanMyStuff::ValidationError] if result_notes is blank
165
- #
166
168
  # @return [void]
167
169
  #
168
170
  def mark_failed!(user, result_notes:)
169
- raise(PMS::ValidationError, 'Result notes are required when failing an item.') if result_notes.blank?
170
- raise(PMS::ValidationError, 'No user configured for sign-off.') if user.blank?
171
+ raise(PlanMyStuff::ValidationError, 'Result notes are required when failing an item.') if result_notes.blank?
172
+ raise(PlanMyStuff::ValidationError, 'No user configured for sign-off.') if user.blank?
171
173
 
172
174
  update_result_notes!(result_notes)
173
175
 
174
- user_id = PMS::UserResolver.user_id(user).to_s
176
+ user_id = PlanMyStuff::UserResolver.user_id(user).to_s
175
177
  current = user_ids_from_field('Failed By')
176
178
  return if current.include?(user_id)
177
179
 
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  # Metadata for testing projects, stored in the GH Project readme field.
5
5
  # Detected at read time by kind: "testing" in the serialized metadata blob.
6
- class TestingProjectMetadata < BaseProjectMetadata
6
+ class TestingProjectMetadata < PlanMyStuff::BaseProjectMetadata
7
7
  # @return [Array<String>] PR/issue URLs this testing project covers
8
8
  attr_accessor :subject_urls
9
9
  # @return [Date, nil] project-level deadline
@@ -86,7 +86,7 @@ module PlanMyStuff
86
86
  def to_h
87
87
  super.merge(
88
88
  subject_urls: subject_urls,
89
- due_date: due_date&.iso8601,
89
+ due_date: PlanMyStuff.format_time(due_date),
90
90
  deadline_miss_reason: deadline_miss_reason,
91
91
  )
92
92
  end
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 6
6
+ MINOR = 8
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
@@ -167,6 +167,8 @@ module PlanMyStuff
167
167
  # a time. Stops on the first non-success so state can be
168
168
  # investigated without skipping ahead.
169
169
  #
170
+ # @raise [PlanMyStuff::Error] when the endpoint returns a non-success HTTP status
171
+ #
170
172
  # @return [void]
171
173
  #
172
174
  def replay_pending(pending, endpoint_url)
@@ -191,14 +193,18 @@ module PlanMyStuff
191
193
  )
192
194
 
193
195
  unless response.is_a?(Net::HTTPSuccess)
194
- raise(' ! non-success; stopping so you can investigate')
196
+ raise(PlanMyStuff::Error, ' ! non-success; stopping so you can investigate')
195
197
  end
196
198
 
197
199
  mark_processed(target[:processed_file], id)
198
200
  end
199
201
  end
200
202
 
203
+ # @raise [ArgumentError] when scope is :repo but repo is nil or empty
204
+ # @raise [ArgumentError] when scope is neither :org nor :repo
205
+ #
201
206
  # @return [String]
207
+ #
202
208
  def hooks_base_path(scope:, repo:)
203
209
  case scope
204
210
  when :org
@@ -212,7 +218,10 @@ module PlanMyStuff
212
218
  end
213
219
  end
214
220
 
221
+ # @raise [PlanMyStuff::Error] when no webhook matches the given URL
222
+ #
215
223
  # @return [Integer]
224
+ #
216
225
  def resolve_hook_id(scope:, repo:, webhook_url:)
217
226
  hooks = gh_get(hooks_base_path(scope: scope, repo: repo), per_page: 100)
218
227
  match = hooks.find { |h| h.dig('config', 'url') == webhook_url }
@@ -247,7 +256,10 @@ module PlanMyStuff
247
256
  File.open(path, 'a') { |f| f.puts(id) }
248
257
  end
249
258
 
259
+ # @raise [PlanMyStuff::Error] when the endpoint returns a non-success HTTP status
260
+ #
250
261
  # @return [void]
262
+ #
251
263
  def replay_each(to_process, deliveries_path, endpoint_url, processed_file, interactive:)
252
264
  to_process.each.with_index(1) do |summary, index|
253
265
  id = summary['id']
@@ -265,7 +277,7 @@ module PlanMyStuff
265
277
  )
266
278
 
267
279
  unless response.is_a?(Net::HTTPSuccess)
268
- raise(' ! non-success; stopping so you can investigate')
280
+ raise(PlanMyStuff::Error, ' ! non-success; stopping so you can investigate')
269
281
  end
270
282
 
271
283
  mark_processed(processed_file, id)
data/lib/plan_my_stuff.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+
3
5
  require 'active_support/core_ext/array/wrap'
4
6
  require 'active_support/core_ext/object/blank'
5
7
 
@@ -44,7 +46,7 @@ module PlanMyStuff
44
46
  class << self
45
47
  # @return [PlanMyStuff::Configuration]
46
48
  def configuration
47
- @configuration ||= Configuration.new
49
+ @configuration ||= PlanMyStuff::Configuration.new
48
50
  end
49
51
 
50
52
  # @return [PlanMyStuff::Configuration]
@@ -54,7 +56,12 @@ module PlanMyStuff
54
56
 
55
57
  # @return [PlanMyStuff::Client]
56
58
  def client
57
- @client ||= Client.new
59
+ @client ||= PlanMyStuff::Client.new
60
+ end
61
+
62
+ # @return [PlanMyStuff::Client]
63
+ def import_client
64
+ @import_client ||= PlanMyStuff::Client.new(importing: true)
58
65
  end
59
66
 
60
67
  # Returns the appropriate HTTP 422 status symbol for the current Rails version.
@@ -70,13 +77,30 @@ module PlanMyStuff
70
77
  end
71
78
  end
72
79
 
80
+ # Formats a time-ish value as an ISO 8601 string. +Time+ and
81
+ # +DateTime+ are normalized to UTC; +Date+ is serialized as-is.
82
+ #
83
+ # @param value [Time, DateTime, Date, String, nil]
84
+ #
85
+ # @return [String, nil]
86
+ #
87
+ def format_time(value)
88
+ return if value.nil?
89
+ return value if value.is_a?(String)
90
+ return value.iso8601 if value.is_a?(Date) && !value.is_a?(DateTime)
91
+
92
+ value.utc.iso8601
93
+ end
94
+
73
95
  # Resets the memoized client and configuration. Useful for testing.
74
96
  #
75
97
  # @return [void]
76
98
  #
77
99
  def reset!
78
100
  exit_test_mode! if defined?(@_test_mode) && @_test_mode
101
+ PlanMyStuff::Client.exit_trace_mode!
79
102
  @client = nil
103
+ @import_client = nil
80
104
  @configuration = nil
81
105
  end
82
106
  end
@@ -18,12 +18,16 @@ namespace :plan_my_stuff do
18
18
  end
19
19
 
20
20
  namespace :webhooks do
21
- desc 'Create an organization webhook (URL=... [EVENTS=ev1,ev2])'
21
+ desc 'Create an organization webhook (URL=... [EVENTS=ev1,ev2]). ' \
22
+ 'Raises PlanMyStuff::ConfigurationError if config.webhook_secret is blank.'
22
23
  task create_org: :environment do
23
- url = ENV.fetch('URL') { raise('URL env var is required') }
24
- events = %w[projects_v2 projects_v2_item projects_v2_status_update]
24
+ url = ENV.fetch('URL') { raise(ArgumentError, 'URL env var is required') }
25
+ default_events = %w[projects_v2 projects_v2_item projects_v2_status_update]
26
+ events = ENV['EVENTS'].present? ? ENV['EVENTS'].split(',').map(&:strip).compact_blank : default_events
25
27
  config = PlanMyStuff.configuration
26
- raise('PlanMyStuff.configuration.webhook_secret is blank') if config.webhook_secret.blank?
28
+ if config.webhook_secret.blank?
29
+ raise(PlanMyStuff::ConfigurationError, 'PlanMyStuff.configuration.webhook_secret is blank')
30
+ end
27
31
 
28
32
  hook = PlanMyStuff.client.rest(
29
33
  :create_org_hook,
@@ -36,16 +40,21 @@ namespace :plan_my_stuff do
36
40
  puts("Events: #{events.join(', ')}")
37
41
  end
38
42
 
39
- desc 'Create a repo webhook (REPO=owner/name URL=... [EVENTS=ev1,ev2])'
43
+ desc 'Create a repo webhook (REPO=owner/name URL=... [EVENTS=ev1,ev2]). ' \
44
+ 'Raises PlanMyStuff::ConfigurationError if config.webhook_secret is blank.'
40
45
  task create_repo: :environment do
41
- url = ENV.fetch('URL') { raise('URL env var is required') }
46
+ url = ENV.fetch('URL') { raise(ArgumentError, 'URL env var is required') }
42
47
  repo = ENV.fetch('REPO') do
43
- PlanMyStuff.client.resolve_repo ||
44
- raise('REPO env var is required or configured (e.g. BrandsInsurance/PlanMyStuff)')
48
+ PlanMyStuff.client.resolve_repo!
49
+ rescue
50
+ raise(ArgumentError, 'REPO env var is required or configured (e.g. BrandsInsurance/PlanMyStuff)')
45
51
  end
46
- events = %w[pull_request issues]
52
+ default_events = %w[pull_request issues]
53
+ events = ENV['EVENTS'].present? ? ENV['EVENTS'].split(',').map(&:strip).compact_blank : default_events
47
54
  config = PlanMyStuff.configuration
48
- raise('PlanMyStuff.configuration.webhook_secret is blank') if config.webhook_secret.blank?
55
+ if config.webhook_secret.blank?
56
+ raise(PlanMyStuff::ConfigurationError, 'PlanMyStuff.configuration.webhook_secret is blank')
57
+ end
49
58
 
50
59
  hook = PlanMyStuff.client.rest(
51
60
  :create_hook,
@@ -64,8 +73,8 @@ namespace :plan_my_stuff do
64
73
  task replay: :environment do
65
74
  require 'plan_my_stuff/webhook_replayer'
66
75
 
67
- endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
68
- webhook_url = ENV.fetch('WEBHOOK_URL') { raise('WEBHOOK_URL env var is required') }
76
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise(ArgumentError, 'ENDPOINT_URL env var is required') }
77
+ webhook_url = ENV.fetch('WEBHOOK_URL') { raise(ArgumentError, 'WEBHOOK_URL env var is required') }
69
78
  scope = ENV.fetch('SCOPE', 'org').to_sym
70
79
  repo = ENV.fetch('REPO', nil)
71
80
  processed_file =
@@ -85,11 +94,12 @@ namespace :plan_my_stuff do
85
94
  end
86
95
 
87
96
  desc 'Continuously poll org + repo hooks and auto-replay new deliveries ' \
88
- '(ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=owner/name] [INTERVAL=15])'
97
+ '(ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=owner/name] [INTERVAL=15]). ' \
98
+ 'At least one of ORG_WEBHOOK_URL or REPO_WEBHOOK_URL is required (raises ArgumentError if both absent).'
89
99
  task listen: :environment do
90
100
  require 'plan_my_stuff/webhook_replayer'
91
101
 
92
- endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
102
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise(ArgumentError, 'ENDPOINT_URL env var is required') }
93
103
  org_webhook_url = ENV.fetch('ORG_WEBHOOK_URL', nil)
94
104
  repo_webhook_url = ENV.fetch('REPO_WEBHOOK_URL', nil)
95
105
  repo = ENV.fetch('REPO', nil)
@@ -98,11 +108,11 @@ namespace :plan_my_stuff do
98
108
  targets = []
99
109
  targets << { scope: :org, webhook_url: org_webhook_url } if org_webhook_url.present?
100
110
  if repo_webhook_url.present?
101
- raise('REPO env var required when REPO_WEBHOOK_URL is set') if repo.blank?
111
+ raise(ArgumentError, 'REPO env var required when REPO_WEBHOOK_URL is set') if repo.blank?
102
112
 
103
113
  targets << { scope: :repo, webhook_url: repo_webhook_url, repo: repo }
104
114
  end
105
- raise('Set at least one of ORG_WEBHOOK_URL or REPO_WEBHOOK_URL') if targets.empty?
115
+ raise(ArgumentError, 'Set at least one of ORG_WEBHOOK_URL or REPO_WEBHOOK_URL') if targets.empty?
106
116
 
107
117
  processed_file_for = -> (target) {
108
118
  Rails.root.join('tmp', 'plan_my_stuff', "webhook_listen_#{target[:scope]}_processed.txt").to_s
@@ -121,10 +131,10 @@ namespace :plan_my_stuff do
121
131
  task simulate_aws: :environment do
122
132
  require 'plan_my_stuff/aws_sns_simulator'
123
133
 
124
- endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
134
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise(ArgumentError, 'ENDPOINT_URL env var is required') }
125
135
  event_name = ENV.fetch('EVENT', PlanMyStuff::AwsSnsSimulator::DEFAULT_EVENT)
126
136
 
127
- PlanMyStuff::AwsSnsSimulator.post(
137
+ PlanMyStuff::AwsSnsSimulator.post!(
128
138
  endpoint_url: endpoint_url,
129
139
  event_name: event_name,
130
140
  )
@@ -133,7 +143,8 @@ namespace :plan_my_stuff do
133
143
 
134
144
  namespace :reminders do
135
145
  desc 'Enqueue a RemindersSweepJob per configured repo ' \
136
- '([REPO=<key>] to target a single repo)'
146
+ '([REPO=<key>] to target a single repo). ' \
147
+ 'Raises PlanMyStuff::ConfigurationError when no repos are configured (config.repos empty and REPO unset).'
137
148
  task sweep: :environment do
138
149
  config = PlanMyStuff.configuration
139
150
  repo_keys =
@@ -143,7 +154,9 @@ namespace :plan_my_stuff do
143
154
  config.repos.keys
144
155
  end
145
156
 
146
- raise('No repos configured (set config.repos or pass REPO=<key>)') if repo_keys.empty?
157
+ if repo_keys.empty?
158
+ raise(PlanMyStuff::ConfigurationError, 'No repos configured (set config.repos or pass REPO=<key>)')
159
+ end
147
160
 
148
161
  repo_keys.each do |key|
149
162
  PlanMyStuff::RemindersSweepJob.requeue(key)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_my_stuff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brands Insurance
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-27 00:00:00.000000000 Z
11
+ date: 2026-04-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -86,6 +86,7 @@ files:
86
86
  - app/views/plan_my_stuff/issues/partials/_links.html.erb
87
87
  - app/views/plan_my_stuff/issues/partials/_viewers.html.erb
88
88
  - app/views/plan_my_stuff/issues/show.html.erb
89
+ - app/views/plan_my_stuff/partials/_flash.html.erb
89
90
  - app/views/plan_my_stuff/projects/edit.html.erb
90
91
  - app/views/plan_my_stuff/projects/index.html.erb
91
92
  - app/views/plan_my_stuff/projects/new.html.erb