plan_my_stuff 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'uri'
7
+
8
+ module PlanMyStuff
9
+ # Dev helper: fetch recent GitHub webhook deliveries via the GitHub API
10
+ # and POST each one to a local endpoint, so a webhook flow can be
11
+ # reproduced against a running server without waiting for GitHub to
12
+ # re-deliver.
13
+ #
14
+ # Intended for use via +plan_my_stuff:webhooks:replay+, not production
15
+ # code paths.
16
+ module WebhookReplayer
17
+ module_function
18
+
19
+ # POST a single webhook delivery to a local endpoint.
20
+ #
21
+ # @param headers [Hash]
22
+ # @param payload [String, Hash, Array]
23
+ # @param endpoint_url [String] full URL including path
24
+ #
25
+ # @return [Net::HTTPResponse]
26
+ #
27
+ def post(headers:, payload:, endpoint_url:)
28
+ uri = URI(endpoint_url)
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == 'https'
31
+ http.open_timeout = 3_600
32
+ http.read_timeout = 3_600
33
+
34
+ body = payload.is_a?(String) ? JSON.parse(payload).to_json : JSON.generate(payload)
35
+
36
+ request = Net::HTTP::Post.new(uri.request_uri)
37
+ headers.each { |key, value| request[key.to_s] = value.to_s }
38
+ request['Content-Type'] ||= 'application/json'
39
+ request.body = body
40
+
41
+ $stdout.puts("POST #{endpoint_url}")
42
+ $stdout.puts("Headers: #{headers.keys.join(', ')}")
43
+ $stdout.puts('---')
44
+
45
+ response = http.request(request)
46
+ $stdout.puts("HTTP #{response.code} #{response.message}")
47
+ $stdout.puts(response.body) if response.body.present?
48
+ response
49
+ end
50
+
51
+ # Fetch deliveries for a hook and replay unseen ones.
52
+ #
53
+ # @param endpoint_url [String] local URL to POST replays to
54
+ # @param webhook_url [String] remote config.url used to resolve hook id
55
+ # @param scope [Symbol, String] :org or :repo
56
+ # @param repo [String, nil] required when scope is :repo
57
+ # @param processed_file [String] state file tracking replayed delivery ids
58
+ # @param interactive [Boolean] prompt after each successful delivery
59
+ #
60
+ # @return [void]
61
+ #
62
+ def fetch_and_replay(endpoint_url:, webhook_url:, processed_file:, scope: :org, repo: nil, interactive: true)
63
+ scope = scope.to_sym
64
+ hook_id = resolve_hook_id(scope: scope, repo: repo, webhook_url: webhook_url)
65
+ deliveries_path = "#{hooks_base_path(scope: scope, repo: repo)}/#{hook_id}/deliveries"
66
+ $stdout.puts("Using #{scope} hook #{hook_id} -> #{webhook_url}")
67
+
68
+ processed = load_processed(processed_file)
69
+ $stdout.puts("Already processed: #{processed.size}")
70
+
71
+ deliveries = gh_get(deliveries_path, per_page: 50).sort_by { |d| d['delivered_at'].to_s }
72
+ to_process = deliveries.reject { |d| processed.include?(d['id'].to_s) }
73
+ $stdout.puts("Fetched #{deliveries.size} deliveries, #{to_process.size} new")
74
+ if to_process.empty?
75
+ $stdout.puts('Nothing to do.')
76
+ return
77
+ end
78
+
79
+ replay_each(to_process, deliveries_path, endpoint_url, processed_file, interactive: interactive)
80
+ end
81
+
82
+ # Polls one or more webhooks on an interval and auto-replays new
83
+ # deliveries as they arrive. Resolves hook ids once up front, then
84
+ # loops forever until Ctrl-C.
85
+ #
86
+ # @param targets [Array<Hash>] one hash per hook, each with
87
+ # +:scope+ (:org / :repo), +:webhook_url+, and +:repo+ (for :repo scope)
88
+ # @param endpoint_url [String] local URL to POST replays to
89
+ # @param processed_file_for [#call] lambda taking a target hash,
90
+ # returns the state file path for that target
91
+ # @param interval [Integer] seconds between polls (default 15)
92
+ #
93
+ # @return [void]
94
+ #
95
+ def listen(targets:, endpoint_url:, processed_file_for:, interval: 30)
96
+ resolved = targets.map { |t| resolve_target(t, processed_file_for) }
97
+ $stdout.puts("Listening on #{resolved.size} hook(s) every #{interval}s. Ctrl-C to stop.")
98
+ resolved.each do |r|
99
+ $stdout.puts(" #{r[:scope]} hook #{r[:hook_id]} -> #{r[:webhook_url]}")
100
+ end
101
+
102
+ loop do
103
+ poll_all(resolved, endpoint_url)
104
+ $stdout.puts('=============')
105
+ $stdout.puts(' Sleeping...')
106
+ $stdout.puts('=============')
107
+ sleep(interval)
108
+ end
109
+ rescue Interrupt
110
+ $stdout.puts("\nStopped.")
111
+ end
112
+
113
+ # Resolves a listen target's hook id and precomputes its deliveries
114
+ # path + processed-file location.
115
+ #
116
+ # @return [Hash]
117
+ #
118
+ def resolve_target(target, processed_file_for)
119
+ scope = target.fetch(:scope).to_sym
120
+ repo = target[:repo]
121
+ webhook_url = target.fetch(:webhook_url)
122
+ hook_id = resolve_hook_id(scope: scope, repo: repo, webhook_url: webhook_url)
123
+
124
+ {
125
+ scope: scope,
126
+ repo: repo,
127
+ webhook_url: webhook_url,
128
+ hook_id: hook_id,
129
+ deliveries_path: "#{hooks_base_path(scope: scope, repo: repo)}/#{hook_id}/deliveries",
130
+ processed_file: processed_file_for.call(target),
131
+ }
132
+ end
133
+
134
+ # Collects unprocessed deliveries across every resolved target,
135
+ # merges them into a single list sorted by +delivered_at+, then
136
+ # replays them one-by-one in true chronological order. Transient
137
+ # API errors per target are logged and swallowed so the listen
138
+ # loop survives.
139
+ #
140
+ # @param resolved [Array<Hash>]
141
+ # @param endpoint_url [String]
142
+ #
143
+ # @return [void]
144
+ #
145
+ def poll_all(resolved, endpoint_url)
146
+ pending = []
147
+ resolved.each do |target|
148
+ processed = load_processed(target[:processed_file])
149
+ deliveries = gh_get(target[:deliveries_path], per_page: 50)
150
+ deliveries.each do |summary|
151
+ next if processed.include?(summary['id'].to_s)
152
+
153
+ pending << { target: target, summary: summary }
154
+ end
155
+ rescue Octokit::Error => e
156
+ warn("[#{target[:scope]}] poll failed: #{e.message}")
157
+ end
158
+
159
+ return if pending.empty?
160
+
161
+ pending.sort_by! { |item| item[:summary]['delivered_at'].to_s }
162
+ $stdout.puts("Replaying #{pending.size} delivery(ies) in delivered_at order")
163
+ replay_pending(pending, endpoint_url)
164
+ end
165
+
166
+ # Replays a pre-sorted, multi-target pending list one delivery at
167
+ # a time. Stops on the first non-success so state can be
168
+ # investigated without skipping ahead.
169
+ #
170
+ # @raise [PlanMyStuff::Error] when the endpoint returns a non-success HTTP status
171
+ #
172
+ # @return [void]
173
+ #
174
+ def replay_pending(pending, endpoint_url)
175
+ pending.each.with_index(1) do |item, index|
176
+ target = item[:target]
177
+ summary = item[:summary]
178
+ id = summary['id']
179
+ delivery = gh_get("#{target[:deliveries_path]}/#{id}")
180
+ request_data = delivery.fetch('request')
181
+ github_delivery_id = request_data.dig('headers', 'X-GitHub-Delivery') || id
182
+
183
+ event = "#{summary['event']}.#{summary['action']}"
184
+ $stdout.puts(
185
+ "[#{index}/#{pending.size}] #{target[:scope]} #{id} #{event} " \
186
+ "@ #{summary['delivered_at']} - #{github_delivery_id}",
187
+ )
188
+
189
+ response = post(
190
+ headers: request_data.fetch('headers') || {},
191
+ payload: request_data.fetch('payload'),
192
+ endpoint_url: endpoint_url,
193
+ )
194
+
195
+ unless response.is_a?(Net::HTTPSuccess)
196
+ raise(PlanMyStuff::Error, ' ! non-success; stopping so you can investigate')
197
+ end
198
+
199
+ mark_processed(target[:processed_file], id)
200
+ end
201
+ end
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
+ #
206
+ # @return [String]
207
+ #
208
+ def hooks_base_path(scope:, repo:)
209
+ case scope
210
+ when :org
211
+ "/orgs/#{PlanMyStuff.configuration.organization}/hooks"
212
+ when :repo
213
+ raise(ArgumentError, 'repo is required for :repo scope') if repo.nil? || repo.to_s.empty?
214
+
215
+ "/repos/#{repo}/hooks"
216
+ else
217
+ raise(ArgumentError, "Unknown scope #{scope.inspect}; expected :org or :repo")
218
+ end
219
+ end
220
+
221
+ # @raise [PlanMyStuff::Error] when no webhook matches the given URL
222
+ #
223
+ # @return [Integer]
224
+ #
225
+ def resolve_hook_id(scope:, repo:, webhook_url:)
226
+ hooks = gh_get(hooks_base_path(scope: scope, repo: repo), per_page: 100)
227
+ match = hooks.find { |h| h.dig('config', 'url') == webhook_url }
228
+ return match['id'] if match.present?
229
+
230
+ available = hooks.map { |h| " id=#{h['id']} url=#{h.dig('config', 'url')}" }.join("\n")
231
+ raise(PlanMyStuff::Error, "No #{scope} webhook with config.url == #{webhook_url}. Visible:\n#{available}")
232
+ end
233
+
234
+ # Calls +octokit.get+ and returns the parsed JSON body (string-keyed
235
+ # Hash/Array) rather than Sawyer::Resource, so delivery payload and
236
+ # headers are easy to re-serialize.
237
+ #
238
+ # @return [Array, Hash]
239
+ #
240
+ def gh_get(path, **params)
241
+ client = PlanMyStuff.client.octokit
242
+ client.get(path, params)
243
+ JSON.parse(client.last_response.body)
244
+ end
245
+
246
+ # @return [Set<String>]
247
+ def load_processed(path)
248
+ return Set.new unless File.exist?(path)
249
+
250
+ File.readlines(path, chomp: true).reject(&:empty?).to_set
251
+ end
252
+
253
+ # @return [void]
254
+ def mark_processed(path, id)
255
+ FileUtils.mkdir_p(File.dirname(path))
256
+ File.open(path, 'a') { |f| f.puts(id) }
257
+ end
258
+
259
+ # @raise [PlanMyStuff::Error] when the endpoint returns a non-success HTTP status
260
+ #
261
+ # @return [void]
262
+ #
263
+ def replay_each(to_process, deliveries_path, endpoint_url, processed_file, interactive:)
264
+ to_process.each.with_index(1) do |summary, index|
265
+ id = summary['id']
266
+ delivery = gh_get("#{deliveries_path}/#{id}")
267
+ request_data = delivery.fetch('request')
268
+ github_delivery_id = request_data.dig('headers', 'X-GitHub-Delivery') || id
269
+
270
+ event = "#{summary['event']}.#{summary['action']}"
271
+ $stdout.puts("[#{index}/#{to_process.size}] #{id} #{event} - #{github_delivery_id}")
272
+
273
+ response = post(
274
+ headers: request_data.fetch('headers') || {},
275
+ payload: request_data.fetch('payload'),
276
+ endpoint_url: endpoint_url,
277
+ )
278
+
279
+ unless response.is_a?(Net::HTTPSuccess)
280
+ raise(PlanMyStuff::Error, ' ! non-success; stopping so you can investigate')
281
+ end
282
+
283
+ mark_processed(processed_file, id)
284
+ next unless interactive
285
+
286
+ $stdout.puts("Processed #{github_delivery_id} - Continue? (y/n)")
287
+ answer = $stdin.gets
288
+ break unless answer&.downcase&.start_with?('y')
289
+ end
290
+ end
291
+ end
292
+ end
data/lib/plan_my_stuff.rb CHANGED
@@ -1,33 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'zeitwerk'
4
+
5
+ require 'date'
6
+
3
7
  require 'active_support/core_ext/array/wrap'
4
8
  require 'active_support/core_ext/object/blank'
5
9
 
6
- require_relative 'plan_my_stuff/application_record'
7
- require_relative 'plan_my_stuff/base_metadata'
8
- require_relative 'plan_my_stuff/client'
9
- require_relative 'plan_my_stuff/comment'
10
- require_relative 'plan_my_stuff/comment_metadata'
11
- require_relative 'plan_my_stuff/configuration'
12
- require_relative 'plan_my_stuff/custom_fields'
13
- require_relative 'plan_my_stuff/engine' if defined?(Rails)
10
+ loader = Zeitwerk::Loader.for_gem
11
+ loader.inflector.inflect(
12
+ 'graphql' => 'GraphQL',
13
+ 'version' => 'VERSION',
14
+ )
15
+ loader.ignore(
16
+ File.join(__dir__, 'generators'),
17
+ File.join(__dir__, 'tasks'),
18
+ File.join(__dir__, 'plan_my_stuff', 'aws_sns_simulator.rb'),
19
+ File.join(__dir__, 'plan_my_stuff', 'engine.rb'),
20
+ File.join(__dir__, 'plan_my_stuff', 'errors.rb'),
21
+ File.join(__dir__, 'plan_my_stuff', 'test_helpers.rb'),
22
+ File.join(__dir__, 'plan_my_stuff', 'webhook_replayer.rb'),
23
+ )
24
+ loader.setup
25
+
26
+ # errors.rb defines several sibling error classes - load it eagerly rather
27
+ # than rely on autoload-via-lead-constant.
14
28
  require_relative 'plan_my_stuff/errors'
15
- require_relative 'plan_my_stuff/issue'
16
- require_relative 'plan_my_stuff/issue_metadata'
17
- require_relative 'plan_my_stuff/label'
18
- require_relative 'plan_my_stuff/markdown'
19
- require_relative 'plan_my_stuff/metadata_parser'
20
- require_relative 'plan_my_stuff/project'
21
- require_relative 'plan_my_stuff/project_item'
22
- require_relative 'plan_my_stuff/user_resolver'
23
- require_relative 'plan_my_stuff/verifier'
24
- require_relative 'plan_my_stuff/version'
29
+
30
+ # Engine must register with Rails at load time, so eagerly require it
31
+ # (and only when Rails is defined - otherwise Rails::Engine is undefined).
32
+ require_relative 'plan_my_stuff/engine' if defined?(Rails)
25
33
 
26
34
  module PlanMyStuff
27
35
  class << self
28
36
  # @return [PlanMyStuff::Configuration]
29
37
  def configuration
30
- @configuration ||= Configuration.new
38
+ @configuration ||= PlanMyStuff::Configuration.new
39
+ end
40
+
41
+ # @return [ActiveSupport::Deprecation]
42
+ def deprecator
43
+ @deprecator ||= ActiveSupport::Deprecation.new('1.0', 'PlanMyStuff')
31
44
  end
32
45
 
33
46
  # @return [PlanMyStuff::Configuration]
@@ -37,7 +50,12 @@ module PlanMyStuff
37
50
 
38
51
  # @return [PlanMyStuff::Client]
39
52
  def client
40
- @client ||= Client.new
53
+ @client ||= PlanMyStuff::Client.new
54
+ end
55
+
56
+ # @return [PlanMyStuff::Client]
57
+ def import_client
58
+ @import_client ||= PlanMyStuff::Client.new(importing: true)
41
59
  end
42
60
 
43
61
  # Returns the appropriate HTTP 422 status symbol for the current Rails version.
@@ -53,13 +71,30 @@ module PlanMyStuff
53
71
  end
54
72
  end
55
73
 
74
+ # Formats a time-ish value as an ISO 8601 string. +Time+ and
75
+ # +DateTime+ are normalized to UTC; +Date+ is serialized as-is.
76
+ #
77
+ # @param value [Time, DateTime, Date, String, nil]
78
+ #
79
+ # @return [String, nil]
80
+ #
81
+ def format_time(value)
82
+ return if value.nil?
83
+ return value if value.is_a?(String)
84
+ return value.iso8601 if value.is_a?(Date) && !value.is_a?(DateTime)
85
+
86
+ value.utc.iso8601
87
+ end
88
+
56
89
  # Resets the memoized client and configuration. Useful for testing.
57
90
  #
58
91
  # @return [void]
59
92
  #
60
93
  def reset!
61
94
  exit_test_mode! if defined?(@_test_mode) && @_test_mode
95
+ PlanMyStuff::Client.exit_trace_mode!
62
96
  @client = nil
97
+ @import_client = nil
63
98
  @configuration = nil
64
99
  end
65
100
  end