plan_my_stuff 0.14.0 → 0.15.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 +41 -0
- data/README.md +47 -39
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +1 -1
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +3 -3
- data/lib/plan_my_stuff/base_project_item.rb +4 -4
- data/lib/plan_my_stuff/comment.rb +2 -2
- data/lib/plan_my_stuff/issue.rb +6 -6
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +13 -13
- data/lib/plan_my_stuff/issue_extractions/links.rb +4 -4
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +4 -4
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +2 -2
- data/lib/plan_my_stuff/label.rb +2 -2
- data/lib/plan_my_stuff/notifications.rb +9 -7
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +1 -1
- data/lib/plan_my_stuff/pipeline.rb +3 -3
- data/lib/plan_my_stuff/reminders/closer.rb +3 -3
- data/lib/plan_my_stuff/reminders/fire.rb +2 -2
- data/lib/plan_my_stuff/test_helpers.rb +6 -6
- data/lib/plan_my_stuff/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a3c6c6445f04d677b6d6b4f0219f57326346b3f42aee6839ab98a0bbf680e63
|
|
4
|
+
data.tar.gz: f5f5adb4f7a11e2d35b907ae7ab1e15d9dc47f31313355be7da186bf9c5086d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a31f3331be41dcd8c4a1369f729888e06ec64474a58c0b56d2c7d57d69b647b1f33b7acc0ae4eed26f03491ce5736a3ab60656900fc31e731bbadd2ae68c492c
|
|
7
|
+
data.tar.gz: 429281e1a5b431ab29fc5c2d91f15176c7556533df7a94d9990dc3e98a198d5b2d603205bd29f5d4efd210c131bc67b7d440de296857f2eeddd5c085fbc799a2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- `ActiveSupport::Notifications` events renamed to follow Rails' convention (event first, library
|
|
8
|
+
last). Subscribers matching the old `plan_my_stuff.*` prefix must switch to `*.plan_my_stuff`.
|
|
9
|
+
|
|
10
|
+
| Old | New |
|
|
11
|
+
|----------------------------------------------|----------------------------------------------|
|
|
12
|
+
| `plan_my_stuff.issue.created` | `issue_created.plan_my_stuff` |
|
|
13
|
+
| `plan_my_stuff.issue.updated` | `issue_updated.plan_my_stuff` |
|
|
14
|
+
| `plan_my_stuff.issue.closed` | `issue_closed.plan_my_stuff` |
|
|
15
|
+
| `plan_my_stuff.issue.reopened` | `issue_reopened.plan_my_stuff` |
|
|
16
|
+
| `plan_my_stuff.issue.archived` | `issue_archived.plan_my_stuff` |
|
|
17
|
+
| `plan_my_stuff.issue.closed_inactive` | `issue_closed_inactive.plan_my_stuff` |
|
|
18
|
+
| `plan_my_stuff.issue.reopened_by_reply` | `issue_reopened_by_reply.plan_my_stuff` |
|
|
19
|
+
| `plan_my_stuff.issue.marked_duplicate` | `issue_marked_duplicate.plan_my_stuff` |
|
|
20
|
+
| `plan_my_stuff.issue.viewers_added` | `issue_viewers_added.plan_my_stuff` |
|
|
21
|
+
| `plan_my_stuff.issue.viewers_removed` | `issue_viewers_removed.plan_my_stuff` |
|
|
22
|
+
| `plan_my_stuff.issue.approval_requested` | `issue_approval_requested.plan_my_stuff` |
|
|
23
|
+
| `plan_my_stuff.issue.approval_granted` | `issue_approval_granted.plan_my_stuff` |
|
|
24
|
+
| `plan_my_stuff.issue.approval_revoked` | `issue_approval_revoked.plan_my_stuff` |
|
|
25
|
+
| `plan_my_stuff.issue.approval_rejected` | `issue_approval_rejected.plan_my_stuff` |
|
|
26
|
+
| `plan_my_stuff.issue.rejection_revoked` | `issue_rejection_revoked.plan_my_stuff` |
|
|
27
|
+
| `plan_my_stuff.issue.all_approved` | `issue_all_approved.plan_my_stuff` |
|
|
28
|
+
| `plan_my_stuff.issue.approvals_invalidated` | `issue_approvals_invalidated.plan_my_stuff` |
|
|
29
|
+
| `plan_my_stuff.issue.reminder_due` | `issue_reminder_due.plan_my_stuff` |
|
|
30
|
+
| `plan_my_stuff.issue.link_reciprocal_failed` | `issue_link_reciprocal_failed.plan_my_stuff` |
|
|
31
|
+
| `plan_my_stuff.comment.created` | `comment_created.plan_my_stuff` |
|
|
32
|
+
| `plan_my_stuff.comment.updated` | `comment_updated.plan_my_stuff` |
|
|
33
|
+
| `plan_my_stuff.label.added` | `label_added.plan_my_stuff` |
|
|
34
|
+
| `plan_my_stuff.label.removed` | `label_removed.plan_my_stuff` |
|
|
35
|
+
| `plan_my_stuff.project_item.added` | `project_item_added.plan_my_stuff` |
|
|
36
|
+
| `plan_my_stuff.project_item.removed` | `project_item_removed.plan_my_stuff` |
|
|
37
|
+
| `plan_my_stuff.project_item.assigned` | `project_item_assigned.plan_my_stuff` |
|
|
38
|
+
| `plan_my_stuff.project_item.status_changed` | `project_item_status_changed.plan_my_stuff` |
|
|
39
|
+
| `plan_my_stuff.pipeline.<status>` | `pipeline_<status>.plan_my_stuff` |
|
|
40
|
+
|
|
41
|
+
Rationale: aligns with `sql.active_record`, `process_action.action_controller`, etc. and lets
|
|
42
|
+
subscribers match "everything from PMS" via the standard suffix-regex (`/\.plan_my_stuff$/`).
|
|
43
|
+
|
|
3
44
|
## 0.14.0
|
|
4
45
|
|
|
5
46
|
### Added
|
data/README.md
CHANGED
|
@@ -267,31 +267,34 @@ A board and its items are editable through the mounted UI at `/testing_projects`
|
|
|
267
267
|
|
|
268
268
|
## Notifications
|
|
269
269
|
|
|
270
|
-
Every PMS lifecycle write fires an `ActiveSupport::Notifications` event under the `
|
|
270
|
+
Every PMS lifecycle write fires an `ActiveSupport::Notifications` event under the `*.plan_my_stuff` namespace
|
|
271
|
+
(Rails convention - event first, library suffix; matches `sql.active_record`, `process_action.action_controller`).
|
|
272
|
+
Subscribe to drive email, webhooks, Slack, ActiveJob, etc. Events fire synchronously (no delays) - subscribers
|
|
273
|
+
that need to defer work should wrap in a background job themselves.
|
|
271
274
|
|
|
272
275
|
### Event catalog
|
|
273
276
|
|
|
274
277
|
| Event | Fired from |
|
|
275
278
|
|-------|------------|
|
|
276
|
-
| `plan_my_stuff
|
|
277
|
-
| `plan_my_stuff
|
|
278
|
-
| `plan_my_stuff
|
|
279
|
-
| `plan_my_stuff
|
|
280
|
-
| `plan_my_stuff
|
|
281
|
-
| `plan_my_stuff
|
|
282
|
-
| `plan_my_stuff
|
|
283
|
-
| `plan_my_stuff
|
|
284
|
-
| `plan_my_stuff
|
|
285
|
-
| `plan_my_stuff
|
|
286
|
-
| `plan_my_stuff
|
|
287
|
-
| `plan_my_stuff
|
|
288
|
-
| `plan_my_stuff
|
|
289
|
-
| `plan_my_stuff
|
|
290
|
-
| `plan_my_stuff
|
|
291
|
-
| `plan_my_stuff
|
|
292
|
-
| `plan_my_stuff
|
|
293
|
-
| `plan_my_stuff
|
|
294
|
-
| `plan_my_stuff
|
|
279
|
+
| `issue_created.plan_my_stuff` | `Issue.create!` |
|
|
280
|
+
| `issue_updated.plan_my_stuff` | `issue.save!` / `issue.update!` (any non-state change) |
|
|
281
|
+
| `issue_closed.plan_my_stuff` | `issue.update!(state: :closed)` |
|
|
282
|
+
| `issue_reopened.plan_my_stuff` | `issue.update!(state: :open)` |
|
|
283
|
+
| `issue_viewers_added.plan_my_stuff` | `issue.add_viewers!` |
|
|
284
|
+
| `issue_viewers_removed.plan_my_stuff` | `issue.remove_viewers!` |
|
|
285
|
+
| `issue_approval_requested.plan_my_stuff` | `issue.request_approvals!` |
|
|
286
|
+
| `issue_approval_granted.plan_my_stuff` | `issue.approve!` |
|
|
287
|
+
| `issue_approval_revoked.plan_my_stuff` | `issue.revoke_approval!` |
|
|
288
|
+
| `issue_all_approved.plan_my_stuff` | aggregate; fires when the set flips to fully approved |
|
|
289
|
+
| `issue_approvals_invalidated.plan_my_stuff` | aggregate; fires on revoke or new approver dropping the set |
|
|
290
|
+
| `comment_created.plan_my_stuff` | `Comment.create!` |
|
|
291
|
+
| `comment_updated.plan_my_stuff` | `comment.save!` / `comment.update!` |
|
|
292
|
+
| `label_added.plan_my_stuff` | `Label.add!` |
|
|
293
|
+
| `label_removed.plan_my_stuff` | `Label.remove!` |
|
|
294
|
+
| `project_item_added.plan_my_stuff` | `ProjectItem.create!` (issue or `draft: true`) |
|
|
295
|
+
| `project_item_removed.plan_my_stuff` | `project_item.destroy!` |
|
|
296
|
+
| `project_item_assigned.plan_my_stuff` | `project_item.assign!` |
|
|
297
|
+
| `project_item_status_changed.plan_my_stuff` | `project_item.move_to!` |
|
|
295
298
|
|
|
296
299
|
### Payload
|
|
297
300
|
|
|
@@ -304,14 +307,14 @@ All events include:
|
|
|
304
307
|
|
|
305
308
|
Additional keys by event:
|
|
306
309
|
|
|
307
|
-
- `
|
|
308
|
-
- `
|
|
309
|
-
- `
|
|
310
|
-
- `
|
|
311
|
-
- `
|
|
312
|
-
- `
|
|
313
|
-
- `
|
|
314
|
-
- `
|
|
310
|
+
- `issue_updated` / `comment_updated` -> `:changes` -- hash of `{ attr => [old, new] }`
|
|
311
|
+
- `issue_viewers_added` / `issue_viewers_removed` -> `:user_ids`
|
|
312
|
+
- `issue_approval_requested` -> `:approvals` (array of newly-added `PMS::Approval`)
|
|
313
|
+
- `issue_approval_granted` / `issue_approval_revoked` -> `:approval` (the flipped `PMS::Approval`)
|
|
314
|
+
- `issue_approvals_invalidated` -> `:trigger` -- `:revoked` or `:approver_added`
|
|
315
|
+
- `label_added` / `label_removed` -> `:labels`
|
|
316
|
+
- `project_item_assigned` -> `:assignees`
|
|
317
|
+
- `project_item_status_changed` -> `:status`, `:previous_status`
|
|
315
318
|
|
|
316
319
|
### Actor resolution
|
|
317
320
|
|
|
@@ -327,7 +330,7 @@ Use `config.current_user = -> { Current.user }` so request-scoped code doesn't h
|
|
|
327
330
|
|
|
328
331
|
```ruby
|
|
329
332
|
# config/initializers/plan_my_stuff_subscribers.rb
|
|
330
|
-
ActiveSupport::Notifications.subscribe(
|
|
333
|
+
ActiveSupport::Notifications.subscribe(/\.plan_my_stuff\z/) do |name, _start, _finish, _id, payload|
|
|
331
334
|
PmsEventMailer.dispatch(name, payload).deliver_later
|
|
332
335
|
end
|
|
333
336
|
```
|
|
@@ -340,13 +343,13 @@ require 'plan_my_stuff/test_helpers'
|
|
|
340
343
|
# Block-style matcher
|
|
341
344
|
expect {
|
|
342
345
|
PMS::Issue.create!(title: 'Bug', body: 'Desc', user: alice)
|
|
343
|
-
}.to(have_fired_event('plan_my_stuff
|
|
346
|
+
}.to(have_fired_event('issue_created.plan_my_stuff').with(user: alice))
|
|
344
347
|
|
|
345
348
|
# Raw capture
|
|
346
349
|
events = PMS::TestHelpers::Notifications.capture do
|
|
347
350
|
PMS::Issue.create!(...)
|
|
348
351
|
end
|
|
349
|
-
events.first[:name] # => "plan_my_stuff
|
|
352
|
+
events.first[:name] # => "issue_created.plan_my_stuff"
|
|
350
353
|
events.first[:payload] # => { issue:, user:, timestamp:, ... }
|
|
351
354
|
```
|
|
352
355
|
|
|
@@ -412,7 +415,9 @@ issue.pending_approvals # [PMS::Approval(user_id: bob.id, status: 'pending')]
|
|
|
412
415
|
|
|
413
416
|
## Reminders
|
|
414
417
|
|
|
415
|
-
Follow-up reminders for issues waiting on an end-user reply or pending approvers. A daily sweep fires
|
|
418
|
+
Follow-up reminders for issues waiting on an end-user reply or pending approvers. A daily sweep fires
|
|
419
|
+
`issue_reminder_due.plan_my_stuff` events on waiting issues whose next milestone has passed; issues that exceed the
|
|
420
|
+
inactivity ceiling are auto-closed.
|
|
416
421
|
|
|
417
422
|
### Waiting on user
|
|
418
423
|
|
|
@@ -433,7 +438,8 @@ A support user enters an issue into "waiting on user" state by either:
|
|
|
433
438
|
|
|
434
439
|
Both paths add the `waiting-on-user` label, set `issue.metadata.waiting_on_user_at = now`, and schedule the first reminder.
|
|
435
440
|
|
|
436
|
-
When an end-user posts a comment through the gem, the label is removed and the waiting state clears. If the issue had
|
|
441
|
+
When an end-user posts a comment through the gem, the label is removed and the waiting state clears. If the issue had
|
|
442
|
+
been auto-closed for inactivity, the reply also reopens it and fires `issue_reopened_by_reply.plan_my_stuff`.
|
|
437
443
|
|
|
438
444
|
### Waiting on approval
|
|
439
445
|
|
|
@@ -454,7 +460,7 @@ issue.metadata.reminder_days = [2, 5, 9]
|
|
|
454
460
|
issue.save!
|
|
455
461
|
```
|
|
456
462
|
|
|
457
|
-
Each reminder emits `plan_my_stuff
|
|
463
|
+
Each reminder emits `issue_reminder_due.plan_my_stuff` with payload:
|
|
458
464
|
|
|
459
465
|
| Field | Type | Notes |
|
|
460
466
|
|---|---|---|
|
|
@@ -468,7 +474,7 @@ Each reminder emits `plan_my_stuff.issue.reminder_due` with payload:
|
|
|
468
474
|
Subscribe in your app to deliver (email, Slack, etc.):
|
|
469
475
|
|
|
470
476
|
```ruby
|
|
471
|
-
ActiveSupport::Notifications.subscribe('plan_my_stuff
|
|
477
|
+
ActiveSupport::Notifications.subscribe('issue_reminder_due.plan_my_stuff') do |event|
|
|
472
478
|
issue = event.payload[:issue]
|
|
473
479
|
case event.payload[:waiting_kind]
|
|
474
480
|
when :user then NotifyUserMailer.reminder(issue).deliver_later
|
|
@@ -484,9 +490,11 @@ When an issue's `days_waiting` reaches `config.inactivity_close_days` (default 3
|
|
|
484
490
|
- Closes the issue
|
|
485
491
|
- Sets `issue.metadata.closed_by_inactivity = true`
|
|
486
492
|
- Adds the `user-inactive` label (configurable via `config.user_inactive_label`)
|
|
487
|
-
- Emits `plan_my_stuff
|
|
493
|
+
- Emits `issue_closed_inactive.plan_my_stuff` (with `reason: :inactivity`); the regular `issue_closed` event is
|
|
494
|
+
suppressed so subscribers listening to "close" don't double-fire for automated closes
|
|
488
495
|
|
|
489
|
-
An end-user reply on a `closed_by_inactivity` issue auto-reopens it, strips the `user-inactive` label, and fires
|
|
496
|
+
An end-user reply on a `closed_by_inactivity` issue auto-reopens it, strips the `user-inactive` label, and fires
|
|
497
|
+
`issue_reopened_by_reply.plan_my_stuff`.
|
|
490
498
|
|
|
491
499
|
### Scheduling the sweep
|
|
492
500
|
|
|
@@ -534,7 +542,7 @@ Closed PMS issues that have aged past `config.archive_closed_after_days` (defaul
|
|
|
534
542
|
2. Lock the conversation on GitHub (no new comments).
|
|
535
543
|
3. Remove the issue from every Projects V2 board it sits on.
|
|
536
544
|
4. Stamp `metadata.archived_at`.
|
|
537
|
-
5. Emit `plan_my_stuff
|
|
545
|
+
5. Emit `issue_archived.plan_my_stuff` with `reason: :aged_closed`.
|
|
538
546
|
|
|
539
547
|
The sweep runs inside `PMS::RemindersSweepJob` alongside the follow-up reminders sweep — same cadence, same rake task.
|
|
540
548
|
|
|
@@ -566,7 +574,7 @@ Locked issues reject new comments through the gem: `PMS::Comment.create!` raises
|
|
|
566
574
|
### Subscribing
|
|
567
575
|
|
|
568
576
|
```ruby
|
|
569
|
-
ActiveSupport::Notifications.subscribe('plan_my_stuff
|
|
577
|
+
ActiveSupport::Notifications.subscribe('issue_archived.plan_my_stuff') do |event|
|
|
570
578
|
issue = event.payload[:issue]
|
|
571
579
|
ArchiveMailer.notify(issue).deliver_later
|
|
572
580
|
end
|
|
@@ -161,7 +161,7 @@ module PlanMyStuff
|
|
|
161
161
|
end
|
|
162
162
|
|
|
163
163
|
# Handles GitHub's +projects_v2_item+ webhook event. Mirrors a dev dragging an item from "Submitted" to
|
|
164
|
-
# "Started" on the project board into a +Pipeline.take!+ call so the +plan_my_stuff
|
|
164
|
+
# "Started" on the project board into a +Pipeline.take!+ call so the +pipeline_started.plan_my_stuff+
|
|
165
165
|
# event fires.
|
|
166
166
|
#
|
|
167
167
|
# Only acts on +action: 'edited'+ where the Status single-select field changed on the pipeline project.
|
|
@@ -108,7 +108,7 @@ PlanMyStuff.configure do |config|
|
|
|
108
108
|
# --------------------------------------------------------------------------
|
|
109
109
|
# Notification actor
|
|
110
110
|
# --------------------------------------------------------------------------
|
|
111
|
-
# Fallback actor for notification events (plan_my_stuff
|
|
111
|
+
# Fallback actor for notification events (*.plan_my_stuff) when a caller
|
|
112
112
|
# does not pass an explicit user: kwarg. Proc/lambda called at event time.
|
|
113
113
|
# config.current_user = -> { Current.user }
|
|
114
114
|
|
|
@@ -215,7 +215,7 @@ PlanMyStuff.configure do |config|
|
|
|
215
215
|
# --------------------------------------------------------------------------
|
|
216
216
|
# Follow-up reminders
|
|
217
217
|
# --------------------------------------------------------------------------
|
|
218
|
-
# Daily sweep that fires `plan_my_stuff
|
|
218
|
+
# Daily sweep that fires `issue_reminder_due.plan_my_stuff` events on
|
|
219
219
|
# waiting issues and auto-closes issues that exceed the inactivity
|
|
220
220
|
# ceiling. Consuming app is responsible for the initial enqueue:
|
|
221
221
|
#
|
|
@@ -251,7 +251,7 @@ PlanMyStuff.configure do |config|
|
|
|
251
251
|
# `archive_closed_after_days` ago (based on GitHub's `closed_at`) the
|
|
252
252
|
# sweep: adds the configured label, locks the conversation, removes
|
|
253
253
|
# the issue from every Projects V2 board, stamps `metadata.archived_at`,
|
|
254
|
-
# and emits `plan_my_stuff
|
|
254
|
+
# and emits `issue_archived.plan_my_stuff` with `reason: :aged_closed`.
|
|
255
255
|
# Issues auto-closed by the inactivity sweep (closed_by_inactivity) are
|
|
256
256
|
# excluded. Non-PMS issues are excluded.
|
|
257
257
|
#
|
|
@@ -109,7 +109,7 @@ module PlanMyStuff
|
|
|
109
109
|
add_item!(issue: issue_or_title, project_number: project_number)
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
PlanMyStuff::Notifications.instrument('
|
|
112
|
+
PlanMyStuff::Notifications.instrument('project_item_added', item, user: user)
|
|
113
113
|
item
|
|
114
114
|
end
|
|
115
115
|
|
|
@@ -463,7 +463,7 @@ module PlanMyStuff
|
|
|
463
463
|
)
|
|
464
464
|
|
|
465
465
|
PlanMyStuff::Notifications.instrument(
|
|
466
|
-
'
|
|
466
|
+
'project_item_status_changed',
|
|
467
467
|
self,
|
|
468
468
|
user: user,
|
|
469
469
|
status: status,
|
|
@@ -520,7 +520,7 @@ module PlanMyStuff
|
|
|
520
520
|
item_id: id,
|
|
521
521
|
)
|
|
522
522
|
|
|
523
|
-
PlanMyStuff::Notifications.instrument('
|
|
523
|
+
PlanMyStuff::Notifications.instrument('project_item_removed', self, user: user)
|
|
524
524
|
destroyed!
|
|
525
525
|
deleted_id
|
|
526
526
|
end
|
|
@@ -544,7 +544,7 @@ module PlanMyStuff
|
|
|
544
544
|
)
|
|
545
545
|
|
|
546
546
|
PlanMyStuff::Notifications.instrument(
|
|
547
|
-
'
|
|
547
|
+
'project_item_assigned',
|
|
548
548
|
self,
|
|
549
549
|
user: user,
|
|
550
550
|
assignees: assignee_list,
|
|
@@ -94,7 +94,7 @@ module PlanMyStuff
|
|
|
94
94
|
mark_issue_responded_if_first_support_comment!(issue, resolved_user) unless skip_responded
|
|
95
95
|
|
|
96
96
|
comment = build(result, issue: issue)
|
|
97
|
-
PlanMyStuff::Notifications.instrument('
|
|
97
|
+
PlanMyStuff::Notifications.instrument('comment_created', comment, user: resolved_user)
|
|
98
98
|
apply_waiting_state_transitions!(issue, resolved_user, waiting_on_reply, comment)
|
|
99
99
|
comment
|
|
100
100
|
end
|
|
@@ -324,7 +324,7 @@ module PlanMyStuff
|
|
|
324
324
|
self.class.update!(id: id, repo: issue.repo, body: serialized)
|
|
325
325
|
|
|
326
326
|
reload
|
|
327
|
-
PlanMyStuff::Notifications.instrument('
|
|
327
|
+
PlanMyStuff::Notifications.instrument('comment_updated', self, user: user, changes: captured_changes)
|
|
328
328
|
self
|
|
329
329
|
end
|
|
330
330
|
|
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -166,7 +166,7 @@ module PlanMyStuff
|
|
|
166
166
|
issue.set_issue_fields!(issue_fields) if issue_fields.present?
|
|
167
167
|
|
|
168
168
|
issue.reload
|
|
169
|
-
PlanMyStuff::Notifications.instrument('
|
|
169
|
+
PlanMyStuff::Notifications.instrument('issue_created', issue, user: user)
|
|
170
170
|
issue
|
|
171
171
|
end
|
|
172
172
|
|
|
@@ -509,7 +509,7 @@ module PlanMyStuff
|
|
|
509
509
|
end
|
|
510
510
|
|
|
511
511
|
# Tags the issue with the configured +archived_label+, removes it from every Projects V2 board it belongs to,
|
|
512
|
-
# locks its conversation on GitHub, and stamps +metadata.archived_at+. Emits +plan_my_stuff
|
|
512
|
+
# locks its conversation on GitHub, and stamps +metadata.archived_at+. Emits +issue_archived.plan_my_stuff+ on
|
|
513
513
|
# success.
|
|
514
514
|
#
|
|
515
515
|
# No-op (no network calls, no event) when the issue is already archived (either +metadata.archived_at+ is set or
|
|
@@ -542,7 +542,7 @@ module PlanMyStuff
|
|
|
542
542
|
reload
|
|
543
543
|
|
|
544
544
|
PlanMyStuff::Notifications.instrument(
|
|
545
|
-
'
|
|
545
|
+
'issue_archived',
|
|
546
546
|
self,
|
|
547
547
|
reason: :aged_closed,
|
|
548
548
|
)
|
|
@@ -786,11 +786,11 @@ module PlanMyStuff
|
|
|
786
786
|
def instrument_update(captured, user)
|
|
787
787
|
case captured['state']
|
|
788
788
|
when %w[open closed]
|
|
789
|
-
PlanMyStuff::Notifications.instrument('
|
|
789
|
+
PlanMyStuff::Notifications.instrument('issue_closed', self, user: user)
|
|
790
790
|
when %w[closed open]
|
|
791
|
-
PlanMyStuff::Notifications.instrument('
|
|
791
|
+
PlanMyStuff::Notifications.instrument('issue_reopened', self, user: user)
|
|
792
792
|
else
|
|
793
|
-
PlanMyStuff::Notifications.instrument('
|
|
793
|
+
PlanMyStuff::Notifications.instrument('issue_updated', self, user: user, changes: captured)
|
|
794
794
|
end
|
|
795
795
|
end
|
|
796
796
|
|
|
@@ -33,8 +33,8 @@ module PlanMyStuff
|
|
|
33
33
|
# Adds approvers to this issue's required-approvals list. Idempotent: users already present are no-ops. Only
|
|
34
34
|
# support users may call this.
|
|
35
35
|
#
|
|
36
|
-
# Fires +plan_my_stuff
|
|
37
|
-
# +plan_my_stuff
|
|
36
|
+
# Fires +issue_approval_requested.plan_my_stuff+ when any user is newly added. Also fires
|
|
37
|
+
# +issue_approvals_invalidated.plan_my_stuff+ (+trigger: :approver_added+) when the new approvers flip the issue
|
|
38
38
|
# out of a fully-approved state.
|
|
39
39
|
#
|
|
40
40
|
# @param user_ids [Array<Integer>, Integer]
|
|
@@ -81,8 +81,8 @@ module PlanMyStuff
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
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
|
|
85
|
-
# approval set, +plan_my_stuff
|
|
84
|
+
# themselves may call this. Fires +issue_approval_granted.plan_my_stuff+ and, when this flip completes the
|
|
85
|
+
# approval set, +issue_all_approved.plan_my_stuff+.
|
|
86
86
|
#
|
|
87
87
|
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already approved
|
|
88
88
|
#
|
|
@@ -109,9 +109,9 @@ module PlanMyStuff
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
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
|
|
112
|
+
# themselves may call this. Fires +issue_approval_rejected.plan_my_stuff+ and, when this flip drops the issue
|
|
113
113
|
# out of +fully_approved?+ (i.e. the caller was the last +approved+ approver),
|
|
114
|
-
# +plan_my_stuff
|
|
114
|
+
# +issue_approvals_invalidated.plan_my_stuff+ (+trigger: :rejected+).
|
|
115
115
|
#
|
|
116
116
|
# @raise [PlanMyStuff::ValidationError] when the caller is not in the approvers list or is already rejected
|
|
117
117
|
#
|
|
@@ -147,9 +147,9 @@ module PlanMyStuff
|
|
|
147
147
|
# may revoke any approver's response by passing +target_user_id:+. Non-support callers passing a
|
|
148
148
|
# +target_user_id:+ that is not their own raise +AuthorizationError+.
|
|
149
149
|
#
|
|
150
|
-
# Emits the granular event keyed off the source state: +plan_my_stuff
|
|
151
|
-
# +plan_my_stuff
|
|
152
|
-
# +fully_approved?+, also fires +plan_my_stuff
|
|
150
|
+
# Emits the granular event keyed off the source state: +issue_approval_revoked.plan_my_stuff+ from approved, or
|
|
151
|
+
# +issue_rejection_revoked.plan_my_stuff+ from rejected. When revoking an approval drops the issue out of
|
|
152
|
+
# +fully_approved?+, also fires +issue_approvals_invalidated.plan_my_stuff+ (+trigger: :revoked+). Revoking a
|
|
153
153
|
# rejection cannot change +fully_approved?+ (the issue was already gated), so no aggregate event fires.
|
|
154
154
|
#
|
|
155
155
|
# @raise [PlanMyStuff::AuthorizationError] when a non-support caller targets another user
|
|
@@ -312,7 +312,7 @@ module PlanMyStuff
|
|
|
312
312
|
return if added.empty?
|
|
313
313
|
|
|
314
314
|
PlanMyStuff::Notifications.instrument(
|
|
315
|
-
'
|
|
315
|
+
'issue_approval_requested',
|
|
316
316
|
self,
|
|
317
317
|
user: user,
|
|
318
318
|
approvals: added,
|
|
@@ -333,7 +333,7 @@ module PlanMyStuff
|
|
|
333
333
|
#
|
|
334
334
|
def finish_state_change(event, approval, user:, was_fully_approved:, trigger: nil)
|
|
335
335
|
PlanMyStuff::Notifications.instrument(
|
|
336
|
-
"
|
|
336
|
+
"issue_#{event}",
|
|
337
337
|
self,
|
|
338
338
|
user: user,
|
|
339
339
|
approval: approval,
|
|
@@ -355,10 +355,10 @@ module PlanMyStuff
|
|
|
355
355
|
now = fully_approved?
|
|
356
356
|
|
|
357
357
|
if !was_fully_approved && now
|
|
358
|
-
PlanMyStuff::Notifications.instrument('
|
|
358
|
+
PlanMyStuff::Notifications.instrument('issue_all_approved', self, user: user)
|
|
359
359
|
elsif was_fully_approved && !now && approvals_required?
|
|
360
360
|
PlanMyStuff::Notifications.instrument(
|
|
361
|
-
'
|
|
361
|
+
'issue_approvals_invalidated',
|
|
362
362
|
self,
|
|
363
363
|
user: user,
|
|
364
364
|
trigger: trigger,
|
|
@@ -210,7 +210,7 @@ module PlanMyStuff
|
|
|
210
210
|
# 6. Closes self with +state_reason: :duplicate+ and
|
|
211
211
|
# +duplicate_of: { owner:, repo:, number: }+.
|
|
212
212
|
# 7. Reloads self; invalidates link caches.
|
|
213
|
-
# 8. Fires +plan_my_stuff
|
|
213
|
+
# 8. Fires +issue_marked_duplicate.plan_my_stuff+.
|
|
214
214
|
#
|
|
215
215
|
# Partial failures are not rolled back - GitHub retains whatever side effects succeeded before the failing step.
|
|
216
216
|
#
|
|
@@ -232,7 +232,7 @@ module PlanMyStuff
|
|
|
232
232
|
|
|
233
233
|
reload
|
|
234
234
|
invalidate_links_cache!
|
|
235
|
-
PlanMyStuff::Notifications.instrument('
|
|
235
|
+
PlanMyStuff::Notifications.instrument('issue_marked_duplicate', self, target: target_issue, user: user)
|
|
236
236
|
|
|
237
237
|
build_link!(target_issue, type: :duplicate_of)
|
|
238
238
|
end
|
|
@@ -303,7 +303,7 @@ module PlanMyStuff
|
|
|
303
303
|
end
|
|
304
304
|
|
|
305
305
|
# Attempts the reciprocal write on +link+'s target. On failure, fires
|
|
306
|
-
# +plan_my_stuff
|
|
306
|
+
# +issue_link_reciprocal_failed.plan_my_stuff+ so the consuming app can surface the half-written pairing.
|
|
307
307
|
#
|
|
308
308
|
# @param link [PlanMyStuff::Link]
|
|
309
309
|
# @param user [Object, nil]
|
|
@@ -315,7 +315,7 @@ module PlanMyStuff
|
|
|
315
315
|
yield(target)
|
|
316
316
|
rescue PlanMyStuff::Error, Octokit::Error => e
|
|
317
317
|
PlanMyStuff::Notifications.instrument(
|
|
318
|
-
'
|
|
318
|
+
'issue_link_reciprocal_failed',
|
|
319
319
|
self,
|
|
320
320
|
user: user,
|
|
321
321
|
link: link,
|
|
@@ -6,7 +6,7 @@ module PlanMyStuff
|
|
|
6
6
|
# Adds user IDs to this issue's visibility allowlist (non-support users whose ID is in the allowlist can see
|
|
7
7
|
# internal comments).
|
|
8
8
|
#
|
|
9
|
-
# Fires +plan_my_stuff
|
|
9
|
+
# Fires +issue_viewers_added.plan_my_stuff+.
|
|
10
10
|
#
|
|
11
11
|
# @param user_ids [Array<Integer>, Integer]
|
|
12
12
|
# @param user [Object, nil] actor for the notification event
|
|
@@ -16,13 +16,13 @@ module PlanMyStuff
|
|
|
16
16
|
def add_viewers!(user_ids:, user: nil)
|
|
17
17
|
ids = Array.wrap(user_ids)
|
|
18
18
|
modify_allowlist! { |allowlist| allowlist | ids }
|
|
19
|
-
PlanMyStuff::Notifications.instrument('
|
|
19
|
+
PlanMyStuff::Notifications.instrument('issue_viewers_added', self, user: user, user_ids: ids)
|
|
20
20
|
metadata.visibility_allowlist
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Removes user IDs from this issue's visibility allowlist.
|
|
24
24
|
#
|
|
25
|
-
# Fires +plan_my_stuff
|
|
25
|
+
# Fires +issue_viewers_removed.plan_my_stuff+.
|
|
26
26
|
#
|
|
27
27
|
# @param user_ids [Array<Integer>, Integer]
|
|
28
28
|
# @param user [Object, nil] actor for the notification event
|
|
@@ -32,7 +32,7 @@ module PlanMyStuff
|
|
|
32
32
|
def remove_viewers!(user_ids:, user: nil)
|
|
33
33
|
ids = Array.wrap(user_ids)
|
|
34
34
|
modify_allowlist! { |allowlist| allowlist - ids }
|
|
35
|
-
PlanMyStuff::Notifications.instrument('
|
|
35
|
+
PlanMyStuff::Notifications.instrument('issue_viewers_removed', self, user: user, user_ids: ids)
|
|
36
36
|
metadata.visibility_allowlist
|
|
37
37
|
end
|
|
38
38
|
|
|
@@ -55,7 +55,7 @@ module PlanMyStuff
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
|
|
58
|
-
# emits +plan_my_stuff
|
|
58
|
+
# emits +issue_reopened_by_reply.plan_my_stuff+ carrying the reopening comment. Does not emit the regular
|
|
59
59
|
# +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
|
|
60
60
|
#
|
|
61
61
|
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
@@ -76,7 +76,7 @@ module PlanMyStuff
|
|
|
76
76
|
reload
|
|
77
77
|
|
|
78
78
|
PlanMyStuff::Notifications.instrument(
|
|
79
|
-
'
|
|
79
|
+
'issue_reopened_by_reply',
|
|
80
80
|
self,
|
|
81
81
|
user: user,
|
|
82
82
|
comment: comment,
|
data/lib/plan_my_stuff/label.rb
CHANGED
|
@@ -28,7 +28,7 @@ module PlanMyStuff
|
|
|
28
28
|
PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
|
|
29
29
|
|
|
30
30
|
PlanMyStuff::Notifications.instrument(
|
|
31
|
-
'
|
|
31
|
+
'label_added', issue, user: user, labels: label_names,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
result.map { |gh_label| build(gh_label, issue: issue) }
|
|
@@ -90,7 +90,7 @@ module PlanMyStuff
|
|
|
90
90
|
PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
|
|
91
91
|
|
|
92
92
|
PlanMyStuff::Notifications.instrument(
|
|
93
|
-
'
|
|
93
|
+
'label_removed', issue, user: user, labels: label_names,
|
|
94
94
|
)
|
|
95
95
|
|
|
96
96
|
results
|
|
@@ -7,18 +7,20 @@ module PlanMyStuff
|
|
|
7
7
|
# +PlanMyStuff::Notifications.instrument+ at mutation points so
|
|
8
8
|
# consuming apps can subscribe for email, webhooks, Slack, etc.
|
|
9
9
|
#
|
|
10
|
-
# Events are fired under the +
|
|
11
|
-
# +ActiveSupport::Notifications
|
|
10
|
+
# Events are fired under the +<event>.plan_my_stuff+ namespace via
|
|
11
|
+
# +ActiveSupport::Notifications+ (Rails convention: event first,
|
|
12
|
+
# library last - matches +sql.active_record+, +deliver.action_mailer+).
|
|
13
|
+
# Subscribers run synchronously.
|
|
12
14
|
#
|
|
13
15
|
module Notifications
|
|
14
16
|
module_function
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
EVENT_SUFFIX = 'plan_my_stuff'
|
|
17
19
|
SKIPPED_LOG_KEYS = %i[user timestamp visibility visibility_allowlist].freeze
|
|
18
20
|
|
|
19
|
-
# Fires +
|
|
21
|
+
# Fires +<event>.plan_my_stuff+ with a normalized payload.
|
|
20
22
|
#
|
|
21
|
-
# @param event [String] e.g. +'
|
|
23
|
+
# @param event [String] e.g. +'issue_created'+
|
|
22
24
|
# @param resource [Object] domain object (+Issue+, +Comment+, +ProjectItem+, ...)
|
|
23
25
|
# @param user [Object, nil] explicit actor; falls back to +config.current_user+
|
|
24
26
|
# @param extra [Hash] additional payload entries (+changes:+, +labels:+, +user_ids:+, ...)
|
|
@@ -29,7 +31,7 @@ module PlanMyStuff
|
|
|
29
31
|
actor = user || resolve_current_user
|
|
30
32
|
payload = build_payload(resource, actor, extra)
|
|
31
33
|
log(event, payload)
|
|
32
|
-
ActiveSupport::Notifications.instrument("#{
|
|
34
|
+
ActiveSupport::Notifications.instrument("#{event}.#{EVENT_SUFFIX}", payload)
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# Invokes +config.current_user+ if it responds to +call+.
|
|
@@ -113,7 +115,7 @@ module PlanMyStuff
|
|
|
113
115
|
logger = rails_logger
|
|
114
116
|
return if logger.nil?
|
|
115
117
|
|
|
116
|
-
logger.debug { "[PlanMyStuff] #{
|
|
118
|
+
logger.debug { "[PlanMyStuff] #{event}.#{EVENT_SUFFIX} #{log_fields(payload)}" }
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
# @return [Logger, nil]
|
|
@@ -7,7 +7,7 @@ module PlanMyStuff
|
|
|
7
7
|
#
|
|
8
8
|
# Driven by +RemindersSweepJob+ so the gem ships one scheduled
|
|
9
9
|
# entrypoint. Items are removed via +Pipeline.remove!+ so subscribers
|
|
10
|
-
# see the standard +plan_my_stuff
|
|
10
|
+
# see the standard +pipeline_removed.plan_my_stuff+ event.
|
|
11
11
|
#
|
|
12
12
|
module CompletedSweep
|
|
13
13
|
module_function
|
|
@@ -49,7 +49,7 @@ module PlanMyStuff
|
|
|
49
49
|
PlanMyStuff.configuration.pipeline_statuses.fetch(canonical, canonical)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# Fires a +plan_my_stuff
|
|
52
|
+
# Fires a +pipeline_<event>.plan_my_stuff+ notification.
|
|
53
53
|
#
|
|
54
54
|
# When +event+ is a canonical +Pipeline::Status+ name, the event key is derived via +Status.key_for+ and the
|
|
55
55
|
# canonical status is added to the payload as +:status+. Otherwise +event+ is used verbatim as the suffix (e.g.
|
|
@@ -70,7 +70,7 @@ module PlanMyStuff
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
PlanMyStuff::Notifications.instrument(
|
|
73
|
-
"
|
|
73
|
+
"pipeline_#{event_to_use}",
|
|
74
74
|
project_item,
|
|
75
75
|
issue_number: project_item.number,
|
|
76
76
|
**extra_to_use,
|
|
@@ -98,7 +98,7 @@ module PlanMyStuff
|
|
|
98
98
|
# Removes a project item from the pipeline project entirely. Captures the prior status before deletion so
|
|
99
99
|
# subscribers can decide whether the removal happened late in the lifecycle.
|
|
100
100
|
#
|
|
101
|
-
# Always fires +plan_my_stuff
|
|
101
|
+
# Always fires +pipeline_removed.plan_my_stuff+. Additionally fires +pipeline_removed_late.plan_my_stuff+ when
|
|
102
102
|
# +prior_status+ is past "Started" (i.e. anything other than "Started", accounting for configured status aliases).
|
|
103
103
|
# +nil+ status is treated as not-late.
|
|
104
104
|
#
|
|
@@ -4,7 +4,7 @@ module PlanMyStuff
|
|
|
4
4
|
module Reminders
|
|
5
5
|
# Auto-closes a waiting issue that has exceeded the configured
|
|
6
6
|
# +inactivity_close_days+ ceiling. Clears waiting state and emits a
|
|
7
|
-
# dedicated +plan_my_stuff
|
|
7
|
+
# dedicated +issue_closed_inactive.plan_my_stuff+ event.
|
|
8
8
|
#
|
|
9
9
|
# Goes through +Issue#update!+ with +skip_notification: true+ so the
|
|
10
10
|
# regular +issue.closed+ event is suppressed in favor of the
|
|
@@ -37,7 +37,7 @@ module PlanMyStuff
|
|
|
37
37
|
|
|
38
38
|
# Closes the issue, tags it with the configured
|
|
39
39
|
# +user_inactive_label+, then emits
|
|
40
|
-
# +plan_my_stuff
|
|
40
|
+
# +issue_closed_inactive.plan_my_stuff+.
|
|
41
41
|
#
|
|
42
42
|
# @return [void]
|
|
43
43
|
#
|
|
@@ -49,7 +49,7 @@ module PlanMyStuff
|
|
|
49
49
|
)
|
|
50
50
|
add_user_inactive_label
|
|
51
51
|
PlanMyStuff::Notifications.instrument(
|
|
52
|
-
'
|
|
52
|
+
'issue_closed_inactive',
|
|
53
53
|
@issue,
|
|
54
54
|
reason: :inactivity,
|
|
55
55
|
)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
4
|
module Reminders
|
|
5
|
-
# Emits +plan_my_stuff
|
|
5
|
+
# Emits +issue_reminder_due.plan_my_stuff+ for a single waiting issue
|
|
6
6
|
# and advances its +next_reminder_at+ to the next milestone in the
|
|
7
7
|
# effective +reminder_days+ schedule (or +nil+ when the last milestone
|
|
8
8
|
# has passed).
|
|
@@ -34,7 +34,7 @@ module PlanMyStuff
|
|
|
34
34
|
#
|
|
35
35
|
def call
|
|
36
36
|
payload = build_payload
|
|
37
|
-
PlanMyStuff::Notifications.instrument('
|
|
37
|
+
PlanMyStuff::Notifications.instrument('issue_reminder_due', @issue, **payload)
|
|
38
38
|
|
|
39
39
|
@issue.update!(
|
|
40
40
|
metadata: { next_reminder_at: next_reminder_at_value },
|
|
@@ -177,7 +177,7 @@ module PlanMyStuff
|
|
|
177
177
|
end
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
-
# Captures +
|
|
180
|
+
# Captures +*.plan_my_stuff+ +ActiveSupport::Notifications+ events fired
|
|
181
181
|
# inside the given block. Lets consuming apps assert that their code
|
|
182
182
|
# triggers gem events without threading subscriptions through every spec.
|
|
183
183
|
#
|
|
@@ -186,7 +186,7 @@ module PlanMyStuff
|
|
|
186
186
|
# events = PlanMyStuff::TestHelpers::Notifications.capture do
|
|
187
187
|
# PlanMyStuff::Issue.create!(...)
|
|
188
188
|
# end
|
|
189
|
-
# events.first[:name] # => "plan_my_stuff
|
|
189
|
+
# events.first[:name] # => "issue_created.plan_my_stuff"
|
|
190
190
|
# events.first[:payload] # => { issue:, user:, timestamp:, ... }
|
|
191
191
|
#
|
|
192
192
|
module Notifications
|
|
@@ -196,7 +196,7 @@ module PlanMyStuff
|
|
|
196
196
|
def capture(&)
|
|
197
197
|
events = []
|
|
198
198
|
callback = -> (name, _start, _finish, _id, payload) { events << { name: name, payload: payload } }
|
|
199
|
-
ActiveSupport::Notifications.subscribed(callback,
|
|
199
|
+
ActiveSupport::Notifications.subscribed(callback, /\.plan_my_stuff\z/, &)
|
|
200
200
|
events
|
|
201
201
|
end
|
|
202
202
|
end
|
|
@@ -708,11 +708,11 @@ module PlanMyStuff
|
|
|
708
708
|
end
|
|
709
709
|
|
|
710
710
|
if defined?(RSpec::Matchers)
|
|
711
|
-
# Asserts that a +
|
|
711
|
+
# Asserts that a +<name>.plan_my_stuff+ event was fired inside the block.
|
|
712
712
|
# Chain +.with(key: value, ...)+ to match on a payload subset.
|
|
713
713
|
#
|
|
714
|
-
# expect { Issue.create!(...) }.to(have_fired_event('plan_my_stuff
|
|
715
|
-
# expect { Issue.create!(...) }.to(have_fired_event('plan_my_stuff
|
|
714
|
+
# expect { Issue.create!(...) }.to(have_fired_event('issue_created.plan_my_stuff'))
|
|
715
|
+
# expect { Issue.create!(...) }.to(have_fired_event('issue_created.plan_my_stuff').with(user: alice))
|
|
716
716
|
#
|
|
717
717
|
RSpec::Matchers.define(:have_fired_event) do |event_name|
|
|
718
718
|
supports_block_expectations
|