plan_my_stuff 0.2.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 (87) 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 +65 -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/edit.html.erb +7 -0
  31. data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
  32. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  33. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  34. data/app/views/plan_my_stuff/projects/show.html.erb +11 -4
  35. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  36. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  37. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  38. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  39. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  40. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  41. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  42. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  43. data/config/routes.rb +38 -15
  44. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  45. data/lib/plan_my_stuff/application_record.rb +144 -0
  46. data/lib/plan_my_stuff/approval.rb +80 -0
  47. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  48. data/lib/plan_my_stuff/archive.rb +14 -0
  49. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  50. data/lib/plan_my_stuff/base_metadata.rb +0 -11
  51. data/lib/plan_my_stuff/base_project.rb +661 -0
  52. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  53. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  54. data/lib/plan_my_stuff/cache.rb +197 -0
  55. data/lib/plan_my_stuff/client.rb +7 -0
  56. data/lib/plan_my_stuff/comment.rb +174 -54
  57. data/lib/plan_my_stuff/configuration.rb +254 -8
  58. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  59. data/lib/plan_my_stuff/engine.rb +0 -4
  60. data/lib/plan_my_stuff/errors.rb +49 -0
  61. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  62. data/lib/plan_my_stuff/issue.rb +1477 -174
  63. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  64. data/lib/plan_my_stuff/label.rb +82 -11
  65. data/lib/plan_my_stuff/link.rb +144 -0
  66. data/lib/plan_my_stuff/notifications.rb +142 -0
  67. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  68. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  69. data/lib/plan_my_stuff/pipeline.rb +293 -0
  70. data/lib/plan_my_stuff/project.rb +62 -468
  71. data/lib/plan_my_stuff/project_item.rb +3 -417
  72. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  73. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  74. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  75. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  76. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  77. data/lib/plan_my_stuff/reminders.rb +16 -0
  78. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  79. data/lib/plan_my_stuff/testing_project.rb +291 -0
  80. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  81. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  82. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  83. data/lib/plan_my_stuff/version.rb +1 -1
  84. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  85. data/lib/plan_my_stuff.rb +16 -0
  86. data/lib/tasks/plan_my_stuff.rake +163 -0
  87. metadata +54 -2
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+ require_relative 'pipeline/issue_linker'
5
+ require_relative 'pipeline/status'
6
+
7
+ module PlanMyStuff
8
+ # High-level orchestration layer for the release pipeline.
9
+ #
10
+ # Named lifecycle methods wrap the low-level +ProjectItem.move_to!+ calls,
11
+ # resolve configurable status aliases, and fire
12
+ # +ActiveSupport::Notifications+ events on every transition.
13
+ #
14
+ # No transition enforcement -- any status can move to any other.
15
+ #
16
+ module Pipeline
17
+ module_function
18
+
19
+ # Raises +PlanMyStuff::PendingApprovalsError+ when +issue+ has
20
+ # pending manager approvals. No-op for +nil+ issues or issues that
21
+ # either have no approvers required or are fully approved.
22
+ #
23
+ # Called at the top of every forward transition (+submit!+, +take!+,
24
+ # +mark_in_review!+, +request_testing!+,
25
+ # +mark_ready_for_release!+). Batch / CI-driven transitions
26
+ # (+start_deployment!+, +complete_deployment!+) and reverse
27
+ # transitions (+remove!+) are intentionally NOT gated -- earlier
28
+ # forward transitions already required approval, and gating
29
+ # batch/automated paths would abort entire deploys on a single
30
+ # approval revoke.
31
+ #
32
+ # @param issue [PlanMyStuff::Issue, nil]
33
+ #
34
+ # @return [void]
35
+ #
36
+ def guard_approvals!(issue)
37
+ return if issue.nil?
38
+
39
+ return unless issue.approvals_required?
40
+
41
+ return if issue.fully_approved?
42
+
43
+ raise(PlanMyStuff::PendingApprovalsError.new(
44
+ issue: issue,
45
+ pending_count: issue.pending_approvals.count,
46
+ ))
47
+ end
48
+
49
+ # Resolves a canonical status name to its configured display alias,
50
+ # falling back to the canonical name when no alias is configured.
51
+ #
52
+ # @param canonical [String] one of the +Pipeline::Status+ constants
53
+ #
54
+ # @return [String]
55
+ #
56
+ def resolve_status_name(canonical)
57
+ PlanMyStuff.configuration.pipeline_statuses.fetch(canonical, canonical)
58
+ end
59
+
60
+ # Fires an +ActiveSupport::Notifications+ event for a pipeline transition.
61
+ #
62
+ # @param status [String] canonical status name (used for event key)
63
+ # @param project_item [PlanMyStuff::ProjectItem]
64
+ # @param extra [Hash] additional payload entries
65
+ #
66
+ # @return [void]
67
+ #
68
+ def instrument(status, project_item, **extra)
69
+ key = PlanMyStuff::Pipeline::Status.key_for(status)
70
+ payload = {
71
+ project_item: project_item,
72
+ issue_number: project_item.number,
73
+ status: status,
74
+ timestamp: Time.current,
75
+ }.merge(extra)
76
+
77
+ ActiveSupport::Notifications.instrument("plan_my_stuff.pipeline.#{key}", payload)
78
+ end
79
+
80
+ # Returns the pipeline project number from the explicit argument,
81
+ # +pipeline_project_number+, or +default_project_number+.
82
+ #
83
+ # @param project_number [Integer, nil]
84
+ #
85
+ # @return [Integer]
86
+ #
87
+ # @raise [ArgumentError] if no project number can be resolved
88
+ #
89
+ def resolve_pipeline_project_number(project_number = nil)
90
+ config = PlanMyStuff.configuration
91
+ result = project_number || config.pipeline_project_number || config.default_project_number
92
+
93
+ raise(ArgumentError, 'No pipeline project number configured') if result.nil?
94
+
95
+ result
96
+ end
97
+
98
+ # Adds an issue to the pipeline project, moves it to "Submitted",
99
+ # and assigns the given developer.
100
+ #
101
+ # Called when an issue is assigned to a dev, NOT on issue creation.
102
+ # Un-triaged issues should not be in the pipeline.
103
+ #
104
+ # @param issue [PlanMyStuff::Issue]
105
+ # @param assignee [String] GitHub username
106
+ # @param project_number [Integer, nil]
107
+ #
108
+ # @return [PlanMyStuff::ProjectItem]
109
+ #
110
+ def submit!(issue, assignee:, project_number: nil)
111
+ guard_approvals!(issue)
112
+ number = resolve_pipeline_project_number(project_number)
113
+ project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
114
+
115
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED)
116
+ project_item.move_to!(status)
117
+ project_item.assign!(assignee)
118
+
119
+ instrument(PlanMyStuff::Pipeline::Status::SUBMITTED, project_item)
120
+ project_item
121
+ end
122
+
123
+ # Removes a project item from the pipeline project entirely.
124
+ # Captures the prior status before deletion so subscribers can
125
+ # decide whether the removal happened late in the lifecycle.
126
+ #
127
+ # Always fires +plan_my_stuff.pipeline.removed+. Additionally fires
128
+ # +plan_my_stuff.pipeline.removed_late+ when +prior_status+ is past
129
+ # "Started" (i.e. anything other than "Submitted" or "Started",
130
+ # accounting for configured status aliases). +nil+ status is treated
131
+ # as not-late.
132
+ #
133
+ # @param project_item [PlanMyStuff::ProjectItem]
134
+ #
135
+ # @return [String, nil] the deleted item ID, or nil if already destroyed
136
+ #
137
+ def remove!(project_item)
138
+ prior_status = project_item.status
139
+ result = project_item.destroy!
140
+
141
+ payload = {
142
+ project_item: project_item,
143
+ issue_number: project_item.number,
144
+ prior_status: prior_status,
145
+ timestamp: Time.current,
146
+ }
147
+
148
+ ActiveSupport::Notifications.instrument('plan_my_stuff.pipeline.removed', payload)
149
+
150
+ if late_removal?(prior_status)
151
+ ActiveSupport::Notifications.instrument('plan_my_stuff.pipeline.removed_late', payload)
152
+ end
153
+
154
+ result
155
+ end
156
+
157
+ # Returns true when +prior_status+ is past "Started" in the pipeline.
158
+ # Honors configured status aliases. +nil+ is treated as not-late.
159
+ #
160
+ # @param prior_status [String, nil]
161
+ #
162
+ # @return [Boolean]
163
+ #
164
+ def late_removal?(prior_status)
165
+ return false if prior_status.nil?
166
+
167
+ early_statuses = [
168
+ resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED),
169
+ resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED),
170
+ ]
171
+
172
+ early_statuses.exclude?(prior_status)
173
+ end
174
+
175
+ # Moves a project item to "Started".
176
+ #
177
+ # @param project_item [PlanMyStuff::ProjectItem]
178
+ #
179
+ # @return [Hash] mutation result
180
+ #
181
+ def take!(project_item)
182
+ guard_approvals!(project_item&.issue)
183
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED)
184
+ result = project_item.move_to!(status)
185
+
186
+ instrument(PlanMyStuff::Pipeline::Status::STARTED, project_item)
187
+ result
188
+ end
189
+
190
+ # Moves a project item to "In Review".
191
+ #
192
+ # @param project_item [PlanMyStuff::ProjectItem]
193
+ #
194
+ # @return [Hash] mutation result
195
+ #
196
+ def mark_in_review!(project_item)
197
+ guard_approvals!(project_item&.issue)
198
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::IN_REVIEW)
199
+ result = project_item.move_to!(status)
200
+
201
+ instrument(PlanMyStuff::Pipeline::Status::IN_REVIEW, project_item)
202
+ result
203
+ end
204
+
205
+ # Moves a project item to "Testing".
206
+ #
207
+ # @param project_item [PlanMyStuff::ProjectItem]
208
+ #
209
+ # @return [Hash] mutation result
210
+ #
211
+ def request_testing!(project_item)
212
+ guard_approvals!(project_item&.issue)
213
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::TESTING)
214
+ result = project_item.move_to!(status)
215
+
216
+ instrument(PlanMyStuff::Pipeline::Status::TESTING, project_item)
217
+ result
218
+ end
219
+
220
+ # Moves a project item to "Ready for Release".
221
+ #
222
+ # @param project_item [PlanMyStuff::ProjectItem]
223
+ #
224
+ # @return [Hash] mutation result
225
+ #
226
+ def mark_ready_for_release!(project_item)
227
+ guard_approvals!(project_item&.issue)
228
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
229
+ result = project_item.move_to!(status)
230
+
231
+ instrument(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE, project_item)
232
+ result
233
+ end
234
+
235
+ # Finds ALL items at "Ready for Release" in the pipeline project and
236
+ # moves each to "Release in Progress". Fires an event per item.
237
+ #
238
+ # When +commit_sha+ is given (the merge_commit_sha of the PR into
239
+ # +production+), it is stamped onto every moved item's linked issue
240
+ # metadata. +AwsController#handle_deployment_completed+ later matches
241
+ # this sha against the configured +production_commit_sha+ to decide
242
+ # which items to auto-complete, so this is the only commit sha in the
243
+ # release cycle that matters.
244
+ #
245
+ # @param project_number [Integer, nil]
246
+ # @param commit_sha [String, nil]
247
+ #
248
+ # @return [Array<PlanMyStuff::ProjectItem>]
249
+ #
250
+ def start_deployment!(project_number: nil, commit_sha: nil)
251
+ number = resolve_pipeline_project_number(project_number)
252
+ project = PlanMyStuff::Project.find(number)
253
+
254
+ ready_status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
255
+ in_progress_status = resolve_status_name(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS)
256
+
257
+ items = project.items.select { |item| item.status == ready_status }
258
+
259
+ items.each do |item|
260
+ if commit_sha.present?
261
+ issue = item.issue
262
+ issue.metadata.commit_sha = commit_sha
263
+ issue.save!
264
+ end
265
+
266
+ item.move_to!(in_progress_status)
267
+ instrument(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS, item, commit_sha: commit_sha)
268
+ end
269
+
270
+ items
271
+ end
272
+
273
+ # Moves a project item to "Completed" if the linked issue has
274
+ # +auto_complete+ enabled. Returns +nil+ when auto-complete is off
275
+ # (item stays at "Release in Progress").
276
+ #
277
+ # @param project_item [PlanMyStuff::ProjectItem]
278
+ # @param deployment_id [String, nil]
279
+ #
280
+ # @return [Hash, nil] mutation result or nil
281
+ #
282
+ def complete_deployment!(project_item, deployment_id: nil)
283
+ issue = project_item.issue
284
+ return unless issue.metadata.auto_complete?
285
+
286
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::COMPLETED)
287
+ result = project_item.move_to!(status)
288
+
289
+ instrument(PlanMyStuff::Pipeline::Status::COMPLETED, project_item, deployment_id: deployment_id)
290
+ result
291
+ end
292
+ end
293
+ end