plan_my_stuff 0.9.0 → 0.10.1

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.
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module IssueExtractions
5
+ module Approvals
6
+ # @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved + rejected)
7
+ def approvers
8
+ metadata.approvals
9
+ end
10
+
11
+ # @return [Array<PlanMyStuff::Approval>] approvers who have not yet acted (pending only; rejections are NOT
12
+ # pending -- the approver has responded)
13
+ def pending_approvals
14
+ approvers.select(&:pending?)
15
+ end
16
+
17
+ # @return [Array<PlanMyStuff::Approval>] approvers who have rejected
18
+ def rejected_approvals
19
+ approvers.select(&:rejected?)
20
+ end
21
+
22
+ # @return [Boolean] true when at least one approver is required on this issue
23
+ def approvals_required?
24
+ approvers.present?
25
+ end
26
+
27
+ # @return [Boolean] true when approvers are required AND every approver has approved. A single rejection blocks
28
+ # this gate until the approver revokes.
29
+ def fully_approved?
30
+ approvals_required? && approvers.all?(&:approved?)
31
+ end
32
+
33
+ # Adds approvers to this issue's required-approvals list. Idempotent: users already present are no-ops. Only
34
+ # support users may call this.
35
+ #
36
+ # Fires +plan_my_stuff.issue.approval_requested+ when any user is newly added. Also fires
37
+ # +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :approver_added+) when the new approvers flip the issue
38
+ # out of a fully-approved state.
39
+ #
40
+ # @param user_ids [Array<Integer>, Integer]
41
+ # @param user [Object, nil] actor; must be a support user
42
+ #
43
+ # @return [Array<PlanMyStuff::Approval>] newly-added approvals (empty when all were duplicates)
44
+ #
45
+ def request_approvals!(user_ids:, user: nil)
46
+ guard_support!(user)
47
+ ids = Array.wrap(user_ids).map(&:to_i)
48
+
49
+ just_added, was_fully_approved = modify_approvals! do |current|
50
+ existing_ids = current.map(&:user_id)
51
+ new_ids = ids - existing_ids
52
+ added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
53
+ [current + added, added]
54
+ end
55
+
56
+ finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved)
57
+ just_added
58
+ end
59
+
60
+ # Removes approvers from this issue's required-approvals list. Only support users may call this. Removing a
61
+ # pending approver may flip the issue into +fully_approved?+ (fires +all_approved+). Removing an approved
62
+ # approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue
63
+ # no longer has +approvals_required?+).
64
+ #
65
+ # @param user_ids [Array<Integer>, Integer]
66
+ # @param user [Object, nil] actor; must be a support user
67
+ #
68
+ # @return [Array<PlanMyStuff::Approval>] removed approval records
69
+ #
70
+ def remove_approvers!(user_ids:, user: nil)
71
+ guard_support!(user)
72
+ ids = Array.wrap(user_ids).map(&:to_i)
73
+
74
+ just_removed, was_fully_approved = modify_approvals! do |current|
75
+ removed = current.select { |a| ids.include?(a.user_id) }
76
+ [current - removed, removed]
77
+ end
78
+
79
+ emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user)
80
+ just_removed
81
+ end
82
+
83
+ # Flips the caller's approval to +approved+ from any other state (+pending+ or +rejected+). Only the approver
84
+ # themselves may call this. Fires +plan_my_stuff.issue.approval_granted+ and, when this flip completes the
85
+ # approval set, +plan_my_stuff.issue.all_approved+.
86
+ #
87
+ # @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
88
+ #
89
+ # @param user [Object, Integer] actor; must resolve to an approver
90
+ #
91
+ # @return [PlanMyStuff::Approval] the updated approval
92
+ #
93
+ def approve!(user:)
94
+ actor_id = resolve_actor_id!(user)
95
+
96
+ just_approved, was_fully_approved = modify_approvals! do |current|
97
+ approval = current.find { |a| a.user_id == actor_id }
98
+ raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
99
+ raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved?
100
+
101
+ approval.status = 'approved'
102
+ approval.approved_at = Time.current
103
+ approval.rejected_at = nil
104
+ [current, approval]
105
+ end
106
+
107
+ finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved)
108
+ just_approved
109
+ end
110
+
111
+ # Flips the caller's approval to +rejected+ from any other state (+pending+ or +approved+). Only the approver
112
+ # themselves may call this. Fires +plan_my_stuff.issue.approval_rejected+ and, when this flip drops the issue
113
+ # out of +fully_approved?+ (i.e. the caller was the last +approved+ approver),
114
+ # +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :rejected+).
115
+ #
116
+ # @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already rejected
117
+ #
118
+ # @param user [Object, Integer] actor; must resolve to an approver
119
+ #
120
+ # @return [PlanMyStuff::Approval] the updated approval
121
+ #
122
+ def reject!(user:)
123
+ actor_id = resolve_actor_id!(user)
124
+
125
+ just_rejected, was_fully_approved = modify_approvals! do |current|
126
+ approval = current.find { |a| a.user_id == actor_id }
127
+ raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
128
+ raise(PlanMyStuff::ValidationError, "User #{actor_id} has already rejected") if approval.rejected?
129
+
130
+ approval.status = 'rejected'
131
+ approval.rejected_at = Time.current
132
+ approval.approved_at = nil
133
+ [current, approval]
134
+ end
135
+
136
+ finish_state_change(
137
+ :approval_rejected,
138
+ just_rejected,
139
+ user: user,
140
+ was_fully_approved: was_fully_approved,
141
+ trigger: :rejected,
142
+ )
143
+ just_rejected
144
+ end
145
+
146
+ # Flips an approved or rejected record back to +pending+. Approvers may revoke their own response; support users
147
+ # may revoke any approver's response by passing +target_user_id:+. Non-support callers passing a
148
+ # +target_user_id:+ that is not their own raise +AuthorizationError+.
149
+ #
150
+ # Emits the granular event keyed off the source state: +plan_my_stuff.issue.approval_revoked+ from approved, or
151
+ # +plan_my_stuff.issue.rejection_revoked+ from rejected. When revoking an approval drops the issue out of
152
+ # +fully_approved?+, also fires +plan_my_stuff.issue.approvals_invalidated+ (+trigger: :revoked+). Revoking a
153
+ # rejection cannot change +fully_approved?+ (the issue was already gated), so no aggregate event fires.
154
+ #
155
+ # @raise [PlanMyStuff::AuthorizationError] when a non-support caller targets another user
156
+ # @raise [PlanMyStuff::ValidationError] when the target is not in the approvers list or is currently pending
157
+ #
158
+ # @param user [Object, Integer] the caller
159
+ # @param target_user_id [Integer, nil] approver whose response should be revoked; defaults to the caller
160
+ #
161
+ # @return [PlanMyStuff::Approval] the updated approval
162
+ #
163
+ def revoke_approval!(user:, target_user_id: nil)
164
+ actor_id = resolve_actor_id!(user)
165
+ caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
166
+ target_id = target_user_id&.to_i || actor_id
167
+
168
+ if !caller_is_support && target_id != actor_id
169
+ raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
170
+ end
171
+
172
+ revoked_from = nil
173
+ just_revoked, was_fully_approved = modify_approvals! do |current|
174
+ approval = current.find { |a| a.user_id == target_id }
175
+ raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
176
+ if approval.pending?
177
+ raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded -- nothing to revoke")
178
+ end
179
+
180
+ revoked_from = approval.status
181
+ approval.status = 'pending'
182
+ approval.approved_at = nil
183
+ approval.rejected_at = nil
184
+ [current, approval]
185
+ end
186
+
187
+ event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
188
+ finish_state_change(
189
+ event,
190
+ just_revoked,
191
+ user: user,
192
+ was_fully_approved: was_fully_approved,
193
+ trigger: (event == :approval_revoked) ? :revoked : nil,
194
+ )
195
+ just_revoked
196
+ end
197
+
198
+ private
199
+
200
+ # Captures +fully_approved?+ state, yields the current approvals (deep-copied) for mutation, persists the new
201
+ # list to GitHub, and reloads +self+. Returns +[extra, was_fully_approved]+.
202
+ #
203
+ # @yieldparam current [Array<PlanMyStuff::Approval>] deep-copied approvals
204
+ # @yieldreturn [Array(Array<PlanMyStuff::Approval>, Object)] +[new_list, extra]+
205
+ #
206
+ # @return [Array(Object, Boolean)]
207
+ #
208
+ def modify_approvals!
209
+ was_fully_approved = fully_approved?
210
+ was_pending_count = metadata.approvals.count(&:pending?)
211
+ current = metadata.approvals.map { |a| PlanMyStuff::Approval.new(a.attributes) }
212
+
213
+ new_list, extra = yield(current)
214
+
215
+ new_pending_count = new_list.count(&:pending?)
216
+ metadata_updates = { approvals: new_list.map(&:to_h) }
217
+ metadata_updates.merge!(waiting_on_approval_metadata_updates(was_pending_count, new_pending_count))
218
+
219
+ self.class.update!(number: number, repo: repo, metadata: metadata_updates)
220
+ reload
221
+
222
+ sync_waiting_on_approval_label!(was_pending_count, new_pending_count)
223
+
224
+ [extra, was_fully_approved]
225
+ end
226
+
227
+ # Computes the metadata delta for the waiting-on-approval timer based on the change in pending-approval count.
228
+ # The timer resets only when pending count goes UP (add approver, revoke-to-pending) so that remaining pending
229
+ # approvers keep their original schedule when a peer approves. Drop-to-zero clears the timer entirely.
230
+ #
231
+ # @param was [Integer] pending count before the mutation
232
+ # @param now [Integer] pending count after the mutation
233
+ #
234
+ # @return [Hash]
235
+ #
236
+ def waiting_on_approval_metadata_updates(was, now)
237
+ if now > was
238
+ ts = Time.now.utc
239
+ {
240
+ waiting_on_approval_at: PlanMyStuff.format_time(ts),
241
+ next_reminder_at: format_next_reminder_at(from: ts),
242
+ }
243
+ elsif now.zero? && was.positive?
244
+ {
245
+ waiting_on_approval_at: nil,
246
+ next_reminder_at: metadata.waiting_on_user_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
247
+ }
248
+ else
249
+ {}
250
+ end
251
+ end
252
+
253
+ # Adds or removes the configured waiting-on-approval label when the pending-approval count crosses the zero
254
+ # boundary. Mutations that stay on the same side of zero leave the label untouched.
255
+ #
256
+ # @param was [Integer] pending count before the mutation
257
+ # @param now [Integer] pending count after the mutation
258
+ #
259
+ # @return [void]
260
+ #
261
+ def sync_waiting_on_approval_label!(was, now)
262
+ label = PlanMyStuff.configuration.waiting_on_approval_label
263
+
264
+ if now.positive? && was.zero?
265
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
266
+ PlanMyStuff::Label.add!(issue: self, labels: [label]) if labels.exclude?(label)
267
+ elsif now.zero? && was.positive?
268
+ PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
269
+ end
270
+ end
271
+
272
+ # Ensures +user+ resolves to a support user. +nil+ user is treated as unauthorized.
273
+ #
274
+ # @raise [PlanMyStuff::AuthorizationError] when the actor is not a support user
275
+ #
276
+ # @param user [Object, Integer, nil]
277
+ #
278
+ # @return [void]
279
+ #
280
+ def guard_support!(user)
281
+ resolved = PlanMyStuff::UserResolver.resolve(user)
282
+ return if resolved && PlanMyStuff::UserResolver.support?(resolved)
283
+
284
+ raise(PlanMyStuff::AuthorizationError, 'Only support users may manage approvers')
285
+ end
286
+
287
+ # Resolves +user+ to an integer user_id.
288
+ #
289
+ # @raise [ArgumentError] when user is nil
290
+ #
291
+ # @param user [Object, Integer]
292
+ #
293
+ # @return [Integer]
294
+ #
295
+ def resolve_actor_id!(user)
296
+ raise(ArgumentError, 'user: is required') if user.nil?
297
+
298
+ resolved = PlanMyStuff::UserResolver.resolve(user)
299
+ PlanMyStuff::UserResolver.user_id(resolved)
300
+ end
301
+
302
+ # Fires +approval_requested+ (when any users were newly added) and, if the aggregate state flipped out of
303
+ # fully-approved, the +approvals_invalidated+ follow-up.
304
+ #
305
+ # @param added [Array<PlanMyStuff::Approval>]
306
+ # @param user [Object, nil]
307
+ # @param was_fully_approved [Boolean]
308
+ #
309
+ # @return [void]
310
+ #
311
+ def finish_request_approvals(added, user:, was_fully_approved:)
312
+ return if added.empty?
313
+
314
+ PlanMyStuff::Notifications.instrument(
315
+ 'issue.approval_requested',
316
+ self,
317
+ user: user,
318
+ approvals: added,
319
+ )
320
+ emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: :approver_added, user: user)
321
+ end
322
+
323
+ # Fires the granular event (+approval_granted+ / +approval_revoked+) then any aggregate follow-up triggered
324
+ # by the state flip.
325
+ #
326
+ # @param event [Symbol] +:approval_granted+ or +:approval_revoked+
327
+ # @param approval [PlanMyStuff::Approval]
328
+ # @param user [Object, nil]
329
+ # @param was_fully_approved [Boolean]
330
+ # @param trigger [Symbol, nil] passed through to +approvals_invalidated+
331
+ #
332
+ # @return [void]
333
+ #
334
+ def finish_state_change(event, approval, user:, was_fully_approved:, trigger: nil)
335
+ PlanMyStuff::Notifications.instrument(
336
+ "issue.#{event}",
337
+ self,
338
+ user: user,
339
+ approval: approval,
340
+ )
341
+ emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: trigger, user: user)
342
+ end
343
+
344
+ # Fires +all_approved+ or +approvals_invalidated+ based on whether +fully_approved?+ flipped. Suppresses
345
+ # +approvals_invalidated+ when the issue no longer has any approvers required (dropping the list to empty is
346
+ # not an invalidation).
347
+ #
348
+ # @param was_fully_approved [Boolean]
349
+ # @param trigger [Symbol, nil]
350
+ # @param user [Object, nil]
351
+ #
352
+ # @return [void]
353
+ #
354
+ def emit_aggregate_events(was_fully_approved:, trigger:, user:)
355
+ now = fully_approved?
356
+
357
+ if !was_fully_approved && now
358
+ PlanMyStuff::Notifications.instrument('issue.all_approved', self, user: user)
359
+ elsif was_fully_approved && !now && approvals_required?
360
+ PlanMyStuff::Notifications.instrument(
361
+ 'issue.approvals_invalidated',
362
+ self,
363
+ user: user,
364
+ trigger: trigger,
365
+ )
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end