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,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module PlanMyStuff
6
+ module Webhooks
7
+ class GithubController < ActionController::API
8
+ before_action :verify_signature
9
+
10
+ # POST /webhooks/github
11
+ def create
12
+ event = request.headers['X-GitHub-Event']
13
+
14
+ case event
15
+ when 'pull_request'
16
+ handle_pull_request
17
+ when 'issues'
18
+ handle_issues
19
+ when 'projects_v2_item'
20
+ handle_projects_v2_item
21
+ end
22
+
23
+ head(:ok)
24
+ end
25
+
26
+ private
27
+
28
+ # Validates HMAC-SHA256 signature from the X-Hub-Signature-256 header.
29
+ #
30
+ # @return [void]
31
+ #
32
+ def verify_signature
33
+ body = request.body.read
34
+ request.body.rewind
35
+
36
+ signature = request.headers['X-Hub-Signature-256'].to_s.gsub('sha256=', '')
37
+ if signature.blank?
38
+ head(:unauthorized)
39
+
40
+ return
41
+ end
42
+
43
+ digest = OpenSSL::Digest.new('sha256')
44
+ secret = PlanMyStuff.configuration.webhook_secret
45
+ hmac = OpenSSL::HMAC.hexdigest(digest, secret, body)
46
+
47
+ return if ActiveSupport::SecurityUtils.secure_compare(hmac, signature)
48
+
49
+ head(:unauthorized)
50
+ end
51
+
52
+ # @return [ActionController::Parameters]
53
+ def payload_params
54
+ @payload_params ||=
55
+ begin
56
+ body = request.body.read
57
+ request.body.rewind
58
+ ActionController::Parameters.new(JSON.parse(body))
59
+ end
60
+ end
61
+
62
+ # @return [ActionController::Parameters]
63
+ def pull_request_params
64
+ payload_params.fetch(:pull_request)
65
+ end
66
+
67
+ # @return [ActionController::Parameters]
68
+ def issue_params
69
+ payload_params.fetch(:issue)
70
+ end
71
+
72
+ # @return [void]
73
+ def handle_issues
74
+ action = payload_params.fetch(:action)
75
+
76
+ case action
77
+ when 'assigned'
78
+ handle_issue_assigned
79
+ when 'unassigned'
80
+ handle_issue_unassigned
81
+ end
82
+ end
83
+
84
+ # Adds the issue to the pipeline project at "Started" the first time it is assigned. Re-assigns and
85
+ # additional assignees are ignored -- if the issue is already in the pipeline we do not touch its status.
86
+ #
87
+ # Skipped paths (no project item is created):
88
+ # - Issue is closed (assignment changes on a closed issue must not alter the pipeline)
89
+ # - Issue has pending approvals (creating + +take!+ would either orphan an item or 500 on the approval guard)
90
+ #
91
+ # GitHub already records the issue assignment (that's what triggered this webhook), so the gem does not
92
+ # call +assign!+ on the project item -- that would clobber co-assignees on the underlying issue.
93
+ #
94
+ # Regardless of pipeline state, +mark_responded!+ stamps +metadata.responded_at+ when a support user is the
95
+ # assignee -- including self-assigns to issues already in the pipeline. The method's own guards no-op for
96
+ # non-support assignees and already-responded issues.
97
+ #
98
+ # @return [void]
99
+ #
100
+ def handle_issue_assigned
101
+ return if issue_params[:state] == 'closed'
102
+
103
+ issue_number = issue_params.fetch(:number)
104
+ assignee_login = payload_params.dig(:assignee, :login)
105
+
106
+ return if assignee_login.blank?
107
+
108
+ repo = payload_params.dig(:repository, :full_name)
109
+ issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
110
+ assignee_user = PlanMyStuff::UserResolver.from_github_login(assignee_login)
111
+
112
+ # Stamp before the already-in-pipeline early-return so a support user self-assigning to an
113
+ # already-taken issue still records a first response.
114
+ issue.mark_responded!(assignee_user)
115
+
116
+ existing = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
117
+ if existing.present?
118
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} already in pipeline project, skipping")
119
+
120
+ return
121
+ end
122
+
123
+ if issue.approvals_required? && !issue.fully_approved?
124
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} has pending approvals, skipping")
125
+
126
+ return
127
+ end
128
+
129
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
130
+ project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
131
+ PlanMyStuff::Pipeline.take!(project_item, user: assignee_user)
132
+ end
133
+
134
+ # Removes the issue from the pipeline project when the LAST assignee is removed. If any assignees remain,
135
+ # the webhook is a no-op (only one of N was removed). If the issue isn't in the pipeline at all, also a
136
+ # no-op (logged at info).
137
+ #
138
+ # Closed issues are skipped: assignment changes on a closed issue must not alter the pipeline. Items
139
+ # already on the release cycle (Ready for Release, Release in Progress, Completed) are also locked --
140
+ # once on the release path an item should not come off via webhook.
141
+ #
142
+ # @return [void]
143
+ #
144
+ def handle_issue_unassigned
145
+ return if issue_params[:state] == 'closed'
146
+
147
+ remaining_assignees = Array.wrap(issue_params[:assignees])
148
+ return if remaining_assignees.present?
149
+
150
+ issue_number = issue_params.fetch(:number)
151
+ project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
152
+
153
+ if project_item.nil?
154
+ Rails.logger.info(
155
+ "[PlanMyStuff] Issue ##{issue_number} not in pipeline project, skipping remove!",
156
+ )
157
+
158
+ return
159
+ end
160
+
161
+ if PlanMyStuff::Pipeline.release_cycle_locked?(project_item)
162
+ Rails.logger.info(
163
+ "[PlanMyStuff] Issue ##{issue_number} on release cycle (#{project_item.status}), skipping remove!",
164
+ )
165
+
166
+ return
167
+ end
168
+
169
+ PlanMyStuff::Pipeline.remove!(project_item)
170
+ end
171
+
172
+ # Handles GitHub's +projects_v2_item+ webhook event. Mirrors a dev dragging an item from "Submitted" to
173
+ # "Started" on the project board into a +Pipeline.take!+ call so the +pipeline_started.plan_my_stuff+
174
+ # event fires.
175
+ #
176
+ # Only acts on +action: 'edited'+ where the Status single-select field changed on the pipeline project.
177
+ # No-ops when the new status is anything other than "Started", when the +from+ status is already "Started"
178
+ # (loop guard from +move_to!+ triggering another webhook), or when the item cannot be located on the
179
+ # pipeline project.
180
+ #
181
+ # @return [void]
182
+ #
183
+ def handle_projects_v2_item
184
+ return unless payload_params[:action] == 'edited'
185
+
186
+ item_project_node_id = payload_params.dig(:projects_v2_item, :project_node_id)
187
+ return if item_project_node_id.blank?
188
+
189
+ pipeline_number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
190
+ project = PlanMyStuff::Project.find(pipeline_number)
191
+ return unless project.id == item_project_node_id
192
+
193
+ field_value = payload_params.dig(:changes, :field_value)
194
+ return if field_value.blank?
195
+
196
+ return unless field_value[:field_name] == 'Status'
197
+
198
+ started_name = PlanMyStuff::Pipeline.resolve_status_name(
199
+ PlanMyStuff::Pipeline::Status::STARTED,
200
+ )
201
+
202
+ to_name = field_value.dig(:to, :name)
203
+ from_name = field_value.dig(:from, :name)
204
+
205
+ # Moved from 'Started'
206
+ return if from_name == started_name
207
+
208
+ # Moved to something other than 'Started'
209
+ return unless to_name == started_name
210
+
211
+ item_node_id = payload_params.dig(:projects_v2_item, :node_id)
212
+ return if item_node_id.blank?
213
+
214
+ project_item = project.items.find { |item| item.id == item_node_id }
215
+
216
+ if project_item.nil?
217
+ Rails.logger.info(
218
+ "[PlanMyStuff] projects_v2_item #{item_node_id} not found in pipeline project, skipping take!",
219
+ )
220
+
221
+ return
222
+ end
223
+
224
+ PlanMyStuff::Pipeline.take!(project_item)
225
+ end
226
+
227
+ # @return [void]
228
+ def handle_pull_request
229
+ action = payload_params.fetch(:action)
230
+
231
+ # Only PRs targeting +main+ drive pipeline transitions on open/draft/reopen. PRs into +production+ are
232
+ # deploy PRs (often auto-created as drafts) and must not bump items back to "Started". The +closed+
233
+ # branch handles its own base-ref routing.
234
+ if %w[opened ready_for_review reopened converted_to_draft].include?(action)
235
+ base_ref = pull_request_params.dig(:base, :ref)
236
+ return unless base_ref == PlanMyStuff.configuration.main_branch
237
+ end
238
+
239
+ case action
240
+ when 'opened'
241
+ pull_request_params[:draft] ? handle_draft_opened : handle_ready_for_review
242
+ when 'ready_for_review', 'reopened'
243
+ handle_ready_for_review
244
+ when 'converted_to_draft'
245
+ handle_converted_to_draft
246
+ when 'closed'
247
+ handle_closed
248
+ end
249
+ end
250
+
251
+ # Opening a PR as a draft is a soft "I've started working on this" signal. For each linked issue, when
252
+ # the issue is not yet in the pipeline, add it at "Started"; when the issue has no assignees, assign the
253
+ # PR author. Already-in-pipeline items are NOT moved (a draft open is not a status change).
254
+ #
255
+ # @return [void]
256
+ #
257
+ def handle_draft_opened
258
+ repo = payload_params.dig(:repository, :full_name)
259
+ pr_author = pull_request_params.dig(:user, :login)
260
+
261
+ each_linked_issue_number do |issue_number|
262
+ issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
263
+
264
+ if PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number).nil?
265
+ ensure_in_pipeline_at_started(issue, pr_author)
266
+ end
267
+
268
+ assign_pr_author(issue, pr_author) if pr_author.present? && issue.assignees.empty?
269
+ end
270
+ end
271
+
272
+ # @return [void]
273
+ def handle_ready_for_review
274
+ each_linked_item do |item|
275
+ PlanMyStuff::Pipeline.mark_in_review!(item)
276
+ end
277
+ end
278
+
279
+ # Adds the issue to the pipeline at "Started". Skipped when the issue has pending approvals
280
+ # (Pipeline.take!'s guard would otherwise leave an orphan project item behind).
281
+ #
282
+ # @param issue [PlanMyStuff::Issue]
283
+ # @param actor_login [String, nil] GitHub login of the webhook actor, mapped to a user via +github_login_for+
284
+ #
285
+ # @return [void]
286
+ #
287
+ def ensure_in_pipeline_at_started(issue, actor_login = nil)
288
+ if issue.approvals_required? && !issue.fully_approved?
289
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue.number} has pending approvals, skipping pipeline add")
290
+ return
291
+ end
292
+
293
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
294
+ project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
295
+ PlanMyStuff::Pipeline.take!(project_item, user: PlanMyStuff::UserResolver.from_github_login(actor_login))
296
+ end
297
+
298
+ # @param issue [PlanMyStuff::Issue]
299
+ # @param pr_author [String]
300
+ #
301
+ # @return [void]
302
+ #
303
+ def assign_pr_author(issue, pr_author)
304
+ PlanMyStuff::Issue.update!(number: issue.number, repo: issue.repo, assignees: [pr_author])
305
+ end
306
+
307
+ # @return [void]
308
+ def handle_converted_to_draft
309
+ each_linked_item do |item|
310
+ PlanMyStuff::Pipeline.take!(item)
311
+ end
312
+ end
313
+
314
+ # @return [void]
315
+ def handle_closed
316
+ return unless pull_request_params[:merged]
317
+
318
+ base_ref = pull_request_params.dig(:base, :ref)
319
+ config = PlanMyStuff.configuration
320
+
321
+ if base_ref == config.main_branch
322
+ each_linked_item do |item|
323
+ PlanMyStuff::Pipeline.mark_ready_for_release!(item)
324
+ end
325
+ elsif base_ref == config.production_branch
326
+ PlanMyStuff::Pipeline.start_deployment!(commit_sha: pull_request_params[:merge_commit_sha])
327
+ end
328
+ end
329
+
330
+ # @yield [PlanMyStuff::ProjectItem]
331
+ def each_linked_item
332
+ each_linked_issue_number do |issue_number|
333
+ item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
334
+
335
+ if item
336
+ yield(item)
337
+ else
338
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} not found in pipeline project, skipping")
339
+ end
340
+ end
341
+ end
342
+
343
+ # @yield [Integer] linked issue number
344
+ def each_linked_issue_number(&)
345
+ messages = fetch_commit_messages
346
+ numbers = PlanMyStuff::Pipeline::IssueLinker.extract_issue_numbers(
347
+ pull_request_params,
348
+ commit_messages: messages,
349
+ )
350
+
351
+ numbers.each(&)
352
+ end
353
+
354
+ # Fetches commit messages for the current PR via the GitHub API.
355
+ #
356
+ # @return [Array<String>] commit messages (empty on failure)
357
+ #
358
+ def fetch_commit_messages
359
+ repo = payload_params.dig(:repository, :full_name)
360
+ pr_number = pull_request_params[:number]
361
+
362
+ # TODO? Add a PR class
363
+ commits = PlanMyStuff.client.rest(:pull_request_commits, repo, pr_number)
364
+ commits.map { |c| c[:commit][:message] }
365
+ rescue PlanMyStuff::APIError => e
366
+ Rails.logger.warn("[PlanMyStuff] Failed to fetch PR commits: #{e.message}")
367
+ []
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Base class for all PMS gem jobs. Subclasses +::ActiveJob::Base+ so the gem doesn't depend on the consuming app
5
+ # having an +ApplicationJob+ constant.
6
+ class ApplicationJob < ::ActiveJob::Base
7
+ end
8
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Daily-cadence sweep job for reminder dispatch + inactivity auto-close. Consuming apps enqueue it once; the job
5
+ # self-requeues after each perform so the schedule persists without requiring a cron/whenever setup.
6
+ # Queue-adapter-agnostic: runs through ActiveJob's +set(wait_until:).perform_later+ so any backend works.
7
+ class RemindersSweepJob < PlanMyStuff::ApplicationJob
8
+ queue_as :default
9
+
10
+ # Only try once per enqueue. Without this, Delayed-style adapters retry up to 25 times on error and our
11
+ # +around_perform+ +ensure+ re-enqueues a follow-up run on every attempt, causing geometric duplicate
12
+ # pile-up. With +attempts: 1+, exactly one perform per +perform_later+; if it fails, the follow-up scheduled
13
+ # in +ensure+ takes over tomorrow.
14
+ retry_on StandardError, attempts: 1
15
+
16
+ around_perform :requeue_for_next_run
17
+
18
+ class << self
19
+ # Next sweep time. Default: 6:30am Eastern tomorrow (today if the current time is before 6:30am ET). Apps
20
+ # wanting a different cadence override this on a subclass.
21
+ #
22
+ # @return [Time] UTC
23
+ #
24
+ def next_run
25
+ now = Time.current.in_time_zone('Eastern Time (US & Canada)')
26
+ target = now.change(hour: 6, min: 30, sec: 0)
27
+ target += 1.day if target <= now
28
+ target.utc
29
+ end
30
+
31
+ # Schedules a sweep for +next_run+. Used by the after-perform self-requeue and the
32
+ # +plan_my_stuff:reminders:sweep+ rake task so both kick off runs on the same cadence (instead of "now").
33
+ #
34
+ # @param repo [Symbol, String]
35
+ #
36
+ # @return [ActiveJob::Base]
37
+ #
38
+ def requeue(repo)
39
+ set(wait_until: next_run).perform_later(repo)
40
+ end
41
+ end
42
+
43
+ # @param repo [Symbol, String]
44
+ #
45
+ # @return [void]
46
+ #
47
+ def perform(repo)
48
+ @repo_arg = repo
49
+ PlanMyStuff::Reminders::Sweep.new(repo: repo).call
50
+ PlanMyStuff::Archive::Sweep.new(repo: repo).call
51
+ PlanMyStuff::Pipeline::CompletedSweep.perform!
52
+ end
53
+
54
+ private
55
+
56
+ # Runs inside +around_perform+. Re-enqueues the next run in an +ensure+ so a perform error still schedules
57
+ # the next one. Skips requeue when perform never captured a repo arg (deserialization error or direct
58
+ # +.perform+ call with missing kwargs).
59
+ #
60
+ # @return [void]
61
+ #
62
+ def requeue_for_next_run
63
+ yield
64
+ ensure
65
+ enqueue_next_run
66
+ end
67
+
68
+ # @return [void]
69
+ def enqueue_next_run
70
+ return if @repo_arg.nil?
71
+
72
+ self.class.requeue(@repo_arg)
73
+ end
74
+ end
75
+ end
@@ -1,8 +1,6 @@
1
1
  <h1>Edit Comment</h1>
2
2
 
3
- <% if flash[:error].present? %>
4
- <p style="color: red;"><%= flash[:error] %></p>
5
- <% end %>
3
+ <%= render({ partial: 'plan_my_stuff/partials/flash' }) %>
6
4
 
7
5
  <%=
8
6
  render({
@@ -1,3 +1,4 @@
1
+ <%# locals: (issue:, comment:, support_user:) %>
1
2
  <%
2
3
  persisted = comment.persisted?
3
4
  url =
@@ -24,6 +25,13 @@
24
25
  )
25
26
  %>
26
27
  </div>
28
+
29
+ <% unless persisted %>
30
+ <div>
31
+ <%= form.check_box(:waiting_on_reply) %>
32
+ <%= form.label(:waiting_on_reply, 'Waiting on reply') %>
33
+ </div>
34
+ <% end %>
27
35
  <% end %>
28
36
 
29
37
  <div>
@@ -1,10 +1,8 @@
1
1
  <h1>Edit Issue #<%= @issue.number %></h1>
2
2
 
3
- <% if flash[:error].present? %>
4
- <p style="color: red;"><%= flash[:error] %></p>
5
- <% end %>
3
+ <%= render({ partial: 'plan_my_stuff/partials/flash' }) %>
6
4
 
7
- <%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue, support_user: @support_user } }) %>
5
+ <%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue } }) %>
8
6
 
9
7
  <% if @support_user %>
10
8
  <hr>
@@ -1,6 +1,6 @@
1
1
  <h1>Issues</h1>
2
2
 
3
- <% if @issues.any? %>
3
+ <% if @issues.present? %>
4
4
  <table>
5
5
  <thead>
6
6
  <tr>
@@ -14,7 +14,7 @@
14
14
  <% @issues.each do |issue| %>
15
15
  <tr>
16
16
  <td><%= issue.number %></td>
17
- <td><%= link_to(issue.title, plan_my_stuff.issue_path(issue.number)) %></td>
17
+ <td><%= link_to(issue.title, plan_my_stuff.issue_path(issue)) %></td>
18
18
  <td><%= issue.state %></td>
19
19
  <td><%= issue.labels.join(', ') %></td>
20
20
  </tr>
@@ -24,14 +24,14 @@
24
24
 
25
25
  <nav>
26
26
  <% if @page > 1 %>
27
- <%= link_to('Previous', plan_my_stuff.issues_path(page: @page - 1, state: @state, labels: @labels)) %>
27
+ <%= link_to('Previous', plan_my_stuff.issues_path(page: @page - 1, state: @state, labels: @labels, repo: @repo)) %>
28
28
  <% end %>
29
29
  <% if @issues.size == @per_page %>
30
- <%= link_to('Next', plan_my_stuff.issues_path(page: @page + 1, state: @state, labels: @labels)) %>
30
+ <%= link_to('Next', plan_my_stuff.issues_path(page: @page + 1, state: @state, labels: @labels, repo: @repo)) %>
31
31
  <% end %>
32
32
  </nav>
33
33
  <% else %>
34
34
  <p>No issues found.</p>
35
35
  <% end %>
36
36
  <br>
37
- <%= link_to "New Issue", plan_my_stuff.new_issue_path %>
37
+ <%= link_to('New Issue', plan_my_stuff.new_issue_path(repo: @repo)) %>
@@ -1,7 +1,5 @@
1
1
  <h1>New Issue</h1>
2
2
 
3
- <% if flash[:error].present? %>
4
- <p style="color: red;"><%= flash[:error] %></p>
5
- <% end %>
3
+ <%= render({ partial: 'plan_my_stuff/partials/flash' }) %>
6
4
 
7
- <%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue, support_user: @support_user } }) %>
5
+ <%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue } }) %>
@@ -0,0 +1,108 @@
1
+ <%# locals: (issue:, support_user:, current_user_id_local:) %>
2
+ <%
3
+ approvers = issue.approvers
4
+ pending_count = issue.pending_approvals.size
5
+ rejected_count = issue.rejected_approvals.size
6
+ current_user_id = current_user_id_local
7
+ %>
8
+
9
+ <% if issue.approvals_required? || support_user %>
10
+ <section aria-label="Manager approvals">
11
+ <h3>Approvals</h3>
12
+
13
+ <% if issue.approvals_required? %>
14
+ <% if issue.fully_approved? %>
15
+ <p><strong>Fully approved</strong></p>
16
+ <% else %>
17
+ <p>
18
+ <strong><%= pending_count %> of <%= approvers.size %> approval(s) pending</strong>
19
+ <% if rejected_count.positive? %>
20
+ (<%= rejected_count %> rejected)
21
+ <% end %>
22
+ </p>
23
+ <% end %>
24
+
25
+ <ul>
26
+ <% approvers.each do |approval| %>
27
+ <li>
28
+ <%= PlanMyStuff::UserResolver.display_name(approval.user) %> &mdash;
29
+ <% if approval.approved? %>
30
+ approved
31
+ <% if approval.approved_at %>
32
+ at <%= approval.approved_at.iso8601 %>
33
+ <% end %>
34
+ <% elsif approval.rejected? %>
35
+ rejected
36
+ <% if approval.rejected_at %>
37
+ at <%= approval.rejected_at.iso8601 %>
38
+ <% end %>
39
+ <% else %>
40
+ pending
41
+ <% end %>
42
+
43
+ <% if approval.pending? && approval.user_id == current_user_id %>
44
+ <%=
45
+ button_to(
46
+ 'Approve',
47
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
48
+ method: :patch,
49
+ params: { approval: { status: 'approved' } },
50
+ form: { style: 'display:inline' },
51
+ )
52
+ %>
53
+ <%=
54
+ button_to(
55
+ 'Reject',
56
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
57
+ method: :patch,
58
+ params: { approval: { status: 'rejected' } },
59
+ form: { style: 'display:inline' },
60
+ )
61
+ %>
62
+ <% end %>
63
+
64
+ <% if !approval.pending? && (approval.user_id == current_user_id || support_user) %>
65
+ <%=
66
+ button_to(
67
+ 'Revoke',
68
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
69
+ method: :patch,
70
+ params: { approval: { status: 'pending' } },
71
+ form: { style: 'display:inline' },
72
+ )
73
+ %>
74
+ <% end %>
75
+
76
+ <% if support_user %>
77
+ <%=
78
+ button_to(
79
+ 'Remove',
80
+ plan_my_stuff.issue_approval_path(issue, approval.user_id),
81
+ method: :delete,
82
+ form: { style: 'display:inline' },
83
+ )
84
+ %>
85
+ <% end %>
86
+ </li>
87
+ <% end %>
88
+ </ul>
89
+ <% else %>
90
+ <p><em>No approvers required</em></p>
91
+ <% end %>
92
+
93
+ <% if support_user %>
94
+ <%=
95
+ form_with(
96
+ scope: :approval,
97
+ url: plan_my_stuff.issue_approvals_path(issue),
98
+ method: :post,
99
+ local: true,
100
+ ) do |form|
101
+ %>
102
+ <%= form.label(:user_ids, 'Add approver IDs (comma-separated)') %>
103
+ <%= form.text_field(:user_ids) %>
104
+ <%= form.submit('Add Approvers') %>
105
+ <% end %>
106
+ <% end %>
107
+ </section>
108
+ <% end %>
@@ -1,9 +1,14 @@
1
- <% persisted = issue.persisted? %>
2
- <%= form_with(
3
- url: persisted ? plan_my_stuff.issue_path(issue.number) : plan_my_stuff.issues_path,
4
- method: persisted ? :patch : :post,
5
- scope: :issue,
6
- ) do |form| %>
1
+ <%# locals: (issue:) %>
2
+ <%
3
+ persisted = issue.persisted?
4
+ url =
5
+ if persisted
6
+ plan_my_stuff.issue_path(issue)
7
+ else
8
+ plan_my_stuff.issues_path(repo: issue.repo&.full_name)
9
+ end
10
+ %>
11
+ <%= form_with(url: url, method: persisted ? :patch : :post, scope: :issue) do |form| %>
7
12
  <div>
8
13
  <%= form.label(:title, 'Title') %>
9
14
  <%= form.text_field(:title, value: issue.title) %>