plan_my_stuff 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +163 -0
  83. metadata +50 -2
@@ -2,6 +2,33 @@
2
2
 
3
3
  module PlanMyStuff
4
4
  class Configuration
5
+ # Default controller for each controllable route group. Consuming apps
6
+ # override by assigning values into +controllers+; lookups go through
7
+ # +controller_for+ which falls back to this table.
8
+ #
9
+ # @return [Hash{Symbol => String}]
10
+ #
11
+ DEFAULT_CONTROLLERS = {
12
+ 'issues': 'plan_my_stuff/issues',
13
+ 'comments': 'plan_my_stuff/comments',
14
+ 'labels': 'plan_my_stuff/labels',
15
+ 'projects': 'plan_my_stuff/projects',
16
+ 'project_items': 'plan_my_stuff/project_items',
17
+ 'testing_projects': 'plan_my_stuff/testing_projects',
18
+ 'testing_project_items': 'plan_my_stuff/testing_project_items',
19
+ 'issues/closures': 'plan_my_stuff/issues/closures',
20
+ 'issues/viewers': 'plan_my_stuff/issues/viewers',
21
+ 'issues/takes': 'plan_my_stuff/issues/takes',
22
+ 'issues/waitings': 'plan_my_stuff/issues/waitings',
23
+ 'issues/links': 'plan_my_stuff/issues/links',
24
+ 'issues/approvals': 'plan_my_stuff/issues/approvals',
25
+ 'project_items/statuses': 'plan_my_stuff/project_items/statuses',
26
+ 'project_items/assignments': 'plan_my_stuff/project_items/assignments',
27
+ 'testing_project_items/results': 'plan_my_stuff/testing_project_items/results',
28
+ 'webhooks/github': 'plan_my_stuff/webhooks/github',
29
+ 'webhooks/aws': 'plan_my_stuff/webhooks/aws',
30
+ }.freeze
31
+
5
32
  # @return [String] GitHub PAT with repo and project scopes. Required.
6
33
  attr_accessor :access_token
7
34
 
@@ -14,6 +41,12 @@ module PlanMyStuff
14
41
  # @return [Integer, nil] default GitHub Projects V2 number for add_to_project calls
15
42
  attr_accessor :default_project_number
16
43
 
44
+ # @return [Integer, nil] GitHub Projects V2 number of the template project to clone
45
+ # when creating new TestingProjects. When set, TestingProject.create! copies the
46
+ # template (preserving its fields and board layout) instead of bootstrapping fields
47
+ # from scratch. Leave nil to use the default bootstrap-fields path.
48
+ attr_accessor :testing_template_project_number
49
+
17
50
  # @return [String] consuming app's user model class name, constantized for lookups
18
51
  attr_accessor :user_class
19
52
 
@@ -61,14 +94,14 @@ module PlanMyStuff
61
94
  #
62
95
  attr_accessor :job_classes
63
96
 
64
- # @return [Proc, nil] custom notifier for deferred requests, or nil to use DeferredMailer
65
- attr_accessor :deferred_notifier
66
-
67
- # @return [String, nil] sender address for built-in deferred request notifications
68
- attr_accessor :deferred_email_from
69
-
70
- # @return [String, nil] recipient address for built-in deferred request notifications
71
- attr_accessor :deferred_email_to
97
+ # Fallback actor for notification events when a caller does not pass +user:+.
98
+ # Set to a proc/lambda that returns the current request user.
99
+ #
100
+ # Example: +config.current_user = -> { Current.user }+
101
+ #
102
+ # @return [Proc, nil]
103
+ #
104
+ attr_accessor :current_user
72
105
 
73
106
  # Shared field definitions stored in issue/comment metadata.
74
107
  # Keys are field names, values are hashes with :type and :required.
@@ -99,6 +132,13 @@ module PlanMyStuff
99
132
  #
100
133
  attr_accessor :project_custom_fields
101
134
 
135
+ # Testing-project-only field definitions, deep-merged on top of shared custom_fields.
136
+ # Context-specific config wins on key conflicts.
137
+ #
138
+ # @return [Hash{Symbol => Hash}]
139
+ #
140
+ attr_accessor :testing_custom_fields
141
+
102
142
  # @return [String, nil] URL prefix for building user-facing ticket URLs in the consuming app
103
143
  attr_accessor :issues_url_prefix
104
144
 
@@ -114,8 +154,14 @@ module PlanMyStuff
114
154
  # @return [String, nil] HMAC secret for GitHub webhook signature verification (required when webhooks mounted)
115
155
  attr_accessor :webhook_secret
116
156
 
117
- # @return [String, nil] shared secret for AWS webhook signature verification (required when AWS webhook mounted)
118
- attr_accessor :aws_webhook_secret
157
+ # @return [String, nil] expected SNS topic ARN for AWS webhook validation
158
+ attr_accessor :sns_topic_arn
159
+
160
+ # @return [String, nil] suffix matched against ECS event resource ARNs (e.g. 'rawr-production-2-web')
161
+ attr_accessor :aws_service_identifier
162
+
163
+ # @return [String, nil] commit hash of the deploying build, prefix-matched against issue metadata commit_sha
164
+ attr_accessor :production_commit_sha
119
165
 
120
166
  # Canonical status name to display alias map. Allows consuming apps to rename
121
167
  # pipeline statuses (e.g. "Submitted" to "Triaged").
@@ -130,6 +176,17 @@ module PlanMyStuff
130
176
  # @return [String] branch name that triggers deployment when a PR merges
131
177
  attr_accessor :production_branch
132
178
 
179
+ # Hash mapping consuming-app user id to GitHub login. Used by the
180
+ # "Take" UI flow to assign the GitHub user when a support user claims
181
+ # an issue. Keys are whatever +config.user_id_method+ returns on the
182
+ # current user.
183
+ #
184
+ # Example: +config.github_login_for = { 1 => 'some_username', 2 => 'octocat' }+
185
+ #
186
+ # @return [Hash{Object => String}]
187
+ #
188
+ attr_accessor :github_login_for
189
+
133
190
  # Per-group route mounting toggles. Keys: :webhooks, :issues, :projects.
134
191
  # Set a key to false to skip mounting that route group.
135
192
  #
@@ -137,6 +194,112 @@ module PlanMyStuff
137
194
  #
138
195
  attr_accessor :mount_groups
139
196
 
197
+ # Per-route controller overrides. Keys are the controllable route symbols
198
+ # defined in +DEFAULT_CONTROLLERS+; values are fully-qualified controller
199
+ # paths (e.g. +'my_app/issues'+). Unset keys fall back to the gem default.
200
+ # Consuming apps typically subclass the gem controller to add before_actions
201
+ # or tweak responses, then swap their subclass in here.
202
+ #
203
+ # @return [Hash{Symbol => String}]
204
+ #
205
+ attr_accessor :controllers
206
+
207
+ # Whether to use Rails.cache for ETag-based HTTP caching of GitHub reads.
208
+ # Defaults to true; set to false to bypass the cache entirely.
209
+ #
210
+ # @return [Boolean]
211
+ #
212
+ attr_accessor :cache_enabled
213
+
214
+ # Opaque app-supplied version string embedded in every PMS cache key.
215
+ # Bumping this string invalidates all cached entries from the consuming
216
+ # app's side (e.g. after a deploy or schema change). Defaults to nil.
217
+ #
218
+ # @return [String, nil]
219
+ #
220
+ attr_accessor :cache_version
221
+
222
+ # Whether the reminders sweep performs any work. Defaults to +true+.
223
+ # Set to +false+ in apps that don't want follow-up reminders or inactivity
224
+ # auto-close.
225
+ #
226
+ # @return [Boolean]
227
+ #
228
+ attr_accessor :reminders_enabled
229
+
230
+ # Days-since-waiting at which reminder events fire. Per-issue override
231
+ # via +metadata.reminder_days+.
232
+ #
233
+ # @return [Array<Integer>]
234
+ #
235
+ attr_accessor :reminder_days
236
+
237
+ # Days of inactivity after which the sweep auto-closes a waiting issue.
238
+ #
239
+ # @return [Integer]
240
+ #
241
+ attr_accessor :inactivity_close_days
242
+
243
+ # Label name used to flag issues waiting on an end-user reply.
244
+ #
245
+ # @return [String]
246
+ #
247
+ attr_accessor :waiting_on_user_label
248
+
249
+ # Label name used to flag issues waiting on pending approvals.
250
+ #
251
+ # @return [String]
252
+ #
253
+ attr_accessor :waiting_on_approval_label
254
+
255
+ # Label name applied to issues auto-closed by the inactivity sweep.
256
+ # Removed when an issue is auto-reopened via a user reply.
257
+ #
258
+ # @return [String]
259
+ #
260
+ attr_accessor :user_inactive_label
261
+
262
+ # Whether the archive sweep performs any work. Defaults to +true+.
263
+ # Set to +false+ in apps that don't want auto-archiving of aged-closed
264
+ # issues.
265
+ #
266
+ # @return [Boolean]
267
+ #
268
+ attr_accessor :archiving_enabled
269
+
270
+ # Days after +closed_at+ at which a non-inactive-closed issue becomes an
271
+ # archive candidate.
272
+ #
273
+ # @return [Integer]
274
+ #
275
+ attr_accessor :archive_closed_after_days
276
+
277
+ # Label name added to archived issues. Also used by the sweep as a
278
+ # skip marker to avoid re-archiving the same issue.
279
+ #
280
+ # @return [String]
281
+ #
282
+ attr_accessor :archived_label
283
+
284
+ # Whether to process incoming AWS webhook events. Defaults to +Rails.env.production?+.
285
+ #
286
+ # @return [Boolean]
287
+ #
288
+ attr_accessor :process_aws_webhooks
289
+
290
+ # Class instantiated per request for SNS signature verification.
291
+ # Must respond to +authenticate!(raw_body)+.
292
+ #
293
+ # @return [Class]
294
+ #
295
+ attr_accessor :sns_verifier_class
296
+
297
+ # Exception class rescued during SNS signature verification.
298
+ #
299
+ # @return [Class]
300
+ #
301
+ attr_accessor :sns_verifier_error
302
+
140
303
  # Named repo configs. Set via config.repos[:element] = 'BrandsInsurance/Element'.
141
304
  #
142
305
  # @return [Hash{Symbol => String}]
@@ -157,11 +320,30 @@ module PlanMyStuff
157
320
  @issue_custom_fields = {}
158
321
  @comment_custom_fields = {}
159
322
  @project_custom_fields = {}
323
+ @testing_custom_fields = {}
160
324
  @pipeline_enabled = true
161
325
  @pipeline_statuses = {}
162
326
  @main_branch = 'main'
163
327
  @production_branch = 'production'
164
328
  @mount_groups = { webhooks: true, issues: true, projects: true }
329
+ @controllers = {}
330
+ @cache_enabled = true
331
+ @github_login_for = {}
332
+ @reminders_enabled = true
333
+ @reminder_days = [1, 3, 7, 10, 14, 18].freeze
334
+ @inactivity_close_days = 30
335
+ @waiting_on_user_label = 'waiting-on-user'
336
+ @waiting_on_approval_label = 'waiting-on-approval'
337
+ @user_inactive_label = 'user-inactive'
338
+ @archiving_enabled = true
339
+ @archive_closed_after_days = 90
340
+ @archived_label = 'archived'
341
+ @process_aws_webhooks = Rails.env.production?
342
+ @sns_verifier_class = ::Aws::SNS::MessageVerifier if defined?(::Aws::SNS::MessageVerifier)
343
+ @sns_verifier_error =
344
+ if defined?(::Aws::SNS::MessageVerifier::VerificationError)
345
+ ::Aws::SNS::MessageVerifier::VerificationError
346
+ end
165
347
  end
166
348
 
167
349
  # Sets the authentication block for engine controllers.
@@ -208,11 +390,29 @@ module PlanMyStuff
208
390
  when :issue then issue_custom_fields
209
391
  when :comment then comment_custom_fields
210
392
  when :project then project_custom_fields
393
+ when :testing then testing_custom_fields
211
394
  else {}
212
395
  end
213
396
 
214
397
  custom_fields.deep_merge(context_fields)
215
398
  end
399
+
400
+ # Returns the controller path for the given route group, preferring a
401
+ # consuming-app override from +controllers+ and falling back to the gem
402
+ # default from +DEFAULT_CONTROLLERS+. The returned value always begins
403
+ # with +'/'+ so the isolated engine does not re-prefix it with
404
+ # +plan_my_stuff/+.
405
+ #
406
+ # @param key [Symbol]
407
+ #
408
+ # @raise [KeyError] if +key+ is not a controllable route group
409
+ #
410
+ # @return [String]
411
+ #
412
+ def controller_for(key)
413
+ path = controllers[key] || DEFAULT_CONTROLLERS.fetch(key)
414
+ path.start_with?('/') ? path : "/#{path}"
415
+ end
216
416
  end
217
417
 
218
418
  class ConfigurationError < StandardError
@@ -60,28 +60,42 @@ module PlanMyStuff
60
60
  to_h.to_json(...)
61
61
  end
62
62
 
63
- private
64
-
65
- def respond_to_missing?(method_name, include_private = false)
66
- key = method_name.to_s.delete_suffix('=').to_sym
67
- @schema.key?(key) || @data.key?(key) || super
68
- end
63
+ # @param method_name [Symbol]
64
+ # @param include_private [Boolean]
65
+ #
66
+ # @return [Boolean]
67
+ #
68
+ def respond_to_missing?(method_name, include_private = false)
69
+ key = method_name.to_s.delete_suffix('=').to_sym
70
+ @schema.key?(key) || @data.key?(key) || super
71
+ end
69
72
 
70
- def method_missing(method_name, *args)
71
- name = method_name.to_s
73
+ # Dynamic reader/writer access to custom field values. Only resolves field
74
+ # names that appear in the schema or already have a value in @data; unknown
75
+ # names fall through to super (raising NoMethodError).
76
+ #
77
+ # @param method_name [Symbol]
78
+ # @param args [Array]
79
+ #
80
+ # @return [Object]
81
+ #
82
+ def method_missing(method_name, *args)
83
+ name = method_name.to_s
72
84
 
73
- if name.end_with?('=')
74
- key = name.delete_suffix('=').to_sym
75
- if @schema.key?(key) || @data.key?(key)
76
- return @data[key] = args.first
77
- end
78
- elsif @schema.key?(method_name) || @data.key?(method_name)
79
- return @data[method_name]
85
+ if name.end_with?('=')
86
+ key = name.delete_suffix('=').to_sym
87
+ if @schema.key?(key) || @data.key?(key)
88
+ return @data[key] = args.first
80
89
  end
81
-
82
- super
90
+ elsif @schema.key?(method_name) || @data.key?(method_name)
91
+ return @data[method_name]
83
92
  end
84
93
 
94
+ super
95
+ end
96
+
97
+ private
98
+
85
99
  # @return [void]
86
100
  def validate_custom_fields
87
101
  validate_unknown_fields
@@ -3,9 +3,5 @@
3
3
  module PlanMyStuff
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace PlanMyStuff
6
-
7
- rake_tasks do
8
- load(File.expand_path('../tasks/plan_my_stuff.rake', __dir__))
9
- end
10
6
  end
11
7
  end
@@ -66,6 +66,17 @@ module PlanMyStuff
66
66
  end
67
67
  end
68
68
 
69
+ # Raised when a webhook HMAC-SHA256 signature check fails
70
+ class WebhookSignatureError < Error
71
+ def initialize(message = 'Invalid webhook signature')
72
+ super
73
+ end
74
+ end
75
+
76
+ # Raised when a pipeline operation fails
77
+ class PipelineError < Error
78
+ end
79
+
69
80
  # Raised when custom field validation fails (Phase 1)
70
81
  class ValidationError < Error
71
82
  # @return [String, nil]
@@ -84,4 +95,42 @@ module PlanMyStuff
84
95
  super(message)
85
96
  end
86
97
  end
98
+
99
+ # Raised when a caller is not authorized to perform an action (e.g.
100
+ # a non-support user attempts to manage approvers on an issue).
101
+ class AuthorizationError < Error
102
+ end
103
+
104
+ # Raised when an operation is attempted on an issue whose conversation
105
+ # is locked on GitHub (archived or manually locked).
106
+ class LockedIssueError < Error
107
+ end
108
+
109
+ # Raised by +PlanMyStuff::Pipeline+ forward transitions when the linked
110
+ # +Issue+ has any pending manager approvals.
111
+ class PendingApprovalsError < ValidationError
112
+ # @return [PlanMyStuff::Issue, nil]
113
+ attr_reader :issue
114
+
115
+ # @return [Integer]
116
+ attr_reader :pending_count
117
+
118
+ # @param message [String, nil]
119
+ # @param issue [PlanMyStuff::Issue, nil]
120
+ # @param pending_count [Integer]
121
+ #
122
+ def initialize(message = nil, issue: nil, pending_count: 0)
123
+ @issue = issue
124
+ @pending_count = pending_count
125
+ super(message || default_message)
126
+ end
127
+
128
+ private
129
+
130
+ # @return [String]
131
+ def default_message
132
+ "Issue ##{issue&.number} has #{pending_count} pending approval(s); " \
133
+ 'cannot move forward through pipeline.'
134
+ end
135
+ end
87
136
  end