plan_my_stuff 0.8.0 → 0.10.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/CONFIGURATION.md +351 -0
- data/app/views/plan_my_stuff/issues/show.html.erb +1 -1
- data/app/views/plan_my_stuff/partials/_flash.html.erb +0 -1
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +1 -12
- data/lib/plan_my_stuff/base_project.rb +5 -176
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +184 -0
- data/lib/plan_my_stuff/base_project_item.rb +1 -0
- data/lib/plan_my_stuff/comment.rb +5 -3
- data/lib/plan_my_stuff/configuration.rb +3 -16
- data/lib/plan_my_stuff/issue.rb +15 -1082
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +148 -0
- data/lib/plan_my_stuff/label.rb +4 -4
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/tasks/plan_my_stuff.rake +2 -2
- metadata +8 -2
|
@@ -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
|