slk 0.7.0 → 0.9.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 +33 -0
- data/README.md +71 -1
- data/lib/slk/api/users.rb +27 -0
- data/lib/slk/cli.rb +2 -1
- data/lib/slk/commands/deactivations.rb +285 -0
- data/lib/slk/commands/help.rb +25 -18
- data/lib/slk/commands/status.rb +142 -17
- data/lib/slk/formatters/deactivation_formatter.rb +108 -0
- data/lib/slk/formatters/json_status_formatter.rb +91 -0
- data/lib/slk/models/deactivation.rb +77 -0
- data/lib/slk/models/dnd_state.rb +81 -0
- data/lib/slk/models/status_snapshot.rb +30 -0
- data/lib/slk/services/deactivation_scanner.rb +105 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +7 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c56010125fc8e78293e4701ac5619742dda1fecd3e6e06ec98a3eb6afab0dc46
|
|
4
|
+
data.tar.gz: 05fad0411c6b216091bc9dd7cade46a36579bbba951042c61859111c57db9356
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ae292c3c50580e4872ec7a2334052b2495d0df8943341970d98f0a1922ea5b15d9612a0b8998de30bfb100100d3daf0567c9270fafef89529fc88c65f72634d
|
|
7
|
+
data.tar.gz: e86c76ce090e6a622a2122c068fb8a384520ff7b38193613f254f446f4a4165f09c48bcf944b2f3d5a2ad3435a0e71502bda8a6ec1b776b0b1b4bbc1ee240424
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.9.0] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`slk deactivations`** — who left the workspace, and when
|
|
13
|
+
- Slack has no "who left" endpoint. It does have `users.list`, which returns departed accounts with `deleted: true` and an `updated` epoch for the last change to the account — and for a deactivated account that change is almost always the deactivation itself. The help text says so out loud, because an admin who edits a departed profile afterwards moves the date forward, and a date that looks authoritative but is not should say which it is
|
|
14
|
+
- Defaults to the 25 most recent departures, one per line with a full ISO date, so the output stays greppable. `-n 0` shows all of them
|
|
15
|
+
- `slk deactivations 90d` (or `--since 90d`, `--since 2026-01-01`) narrows to a window; `--grep` filters across name, handle, title, email and user ID
|
|
16
|
+
- `--chart` draws departures per calendar month across the whole window asked for, quiet months filled in with zero — a gap in a histogram should read as "nobody left", not as a month that never happened, and a quiet month at either end of the window still happened. Without `--since` it covers the last twelve months rather than the entire history of the workspace
|
|
17
|
+
- An account Slack never dated cannot answer a question about a window, so it drops out of one — but the footer says how many did, rather than discarding them in silence
|
|
18
|
+
- Bots and app users are excluded from the counts and the list; `--bots` puts them back
|
|
19
|
+
- The roster is one API call per 1000 members, so the derived result is cached for six hours. The footer says how old it is, because "nobody left this week" and "nobody left since the last time you asked" are different statements. `--refresh` re-fetches
|
|
20
|
+
|
|
21
|
+
## [0.8.0] - 2026-08-30
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`slk status` now answers the questions the status raises** — who can reach you, and whether the status is about to change on its own
|
|
26
|
+
- Presence and DND appear as suffixes on the status line: `:computer: Working [away] [dnd until 3:00pm]`. Only exceptional states are labelled, so the workspace that differs stands out rather than being buried under "active" on every line
|
|
27
|
+
- `[dnd until …]` covers a manual snooze and the configured DND hours alike — from the outside both mean messages do not notify — and reports the later end when both apply. `slk dnd` still breaks out which is which
|
|
28
|
+
- Anything scheduled to turn on later is listed under the status, soonest first and without IDs; `slk status scheduled` remains the view to paste an ID from
|
|
29
|
+
- Times carry a day when the end is not today, so an overnight window does not read as a time that has already passed
|
|
30
|
+
- **`slk status --json`** (and `slk status scheduled --json`) for scripts and statuslines
|
|
31
|
+
- Always an array, one entry per workspace, even for a single workspace: a document whose shape changes with `-w` cannot be parsed by a script that did not pass it
|
|
32
|
+
- `null` means "not checked" — skipped by a flag, or a lookup that failed — as distinct from checked-and-empty. A statusline that read a failed DND lookup as "DND off" would report the opposite of the truth
|
|
33
|
+
- Every timestamp appears twice: Slack's own epoch under Slack's own field name, and an ISO 8601 string beside it
|
|
34
|
+
- **`--brief` and `--no-scheduled`** for the get view. It now costs up to four calls per workspace; `--no-scheduled` drops the internal `users.customStatus.list` lookup and `--brief` drops all three extras. `--quiet` skips them too, since their output would be discarded (`--json` still gathers them: it prints regardless)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- A failed presence, DND or schedule lookup during `slk status` warns and omits that part instead of failing the command — the status itself is what was asked for. A rate limit stops the remaining extra lookups altogether rather than spending calls the status reads need
|
|
39
|
+
|
|
8
40
|
## [0.7.0] - 2026-08-03
|
|
9
41
|
|
|
10
42
|
### Added
|
|
@@ -210,6 +242,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
|
|
|
210
242
|
- Pure Ruby stdlib - no gem dependencies
|
|
211
243
|
- Ruby 3.2+ with modern features (Data.define, pattern matching)
|
|
212
244
|
|
|
245
|
+
[0.8.0]: https://github.com/ericboehs/slk/releases/tag/v0.8.0
|
|
213
246
|
[0.7.0]: https://github.com/ericboehs/slk/releases/tag/v0.7.0
|
|
214
247
|
[0.6.0]: https://github.com/ericboehs/slk/releases/tag/v0.6.0
|
|
215
248
|
[0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
|
data/README.md
CHANGED
|
@@ -46,12 +46,52 @@ You'll need a Slack token. Get one from:
|
|
|
46
46
|
### Status
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
slk status #
|
|
49
|
+
slk status # Status, presence, DND and what's queued next
|
|
50
|
+
slk status --brief # Status text only (one call per workspace)
|
|
51
|
+
slk status --json # Machine-readable, for scripts and statuslines
|
|
50
52
|
slk status "Working from home" :house: # Set status with emoji
|
|
51
53
|
slk status "In a meeting" :calendar: 1h # Set status for 1 hour
|
|
52
54
|
slk status clear # Clear status
|
|
53
55
|
```
|
|
54
56
|
|
|
57
|
+
Getting the status also answers the two questions it usually raises — can anyone
|
|
58
|
+
reach me, and is this about to change on its own:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
$ slk status
|
|
62
|
+
work
|
|
63
|
+
:computer: Working [away] [dnd until 3:00pm]
|
|
64
|
+
Scheduled:
|
|
65
|
+
:paw_prints: Vet Appt (Mon Aug 3 1:30pm -> 3:30pm)
|
|
66
|
+
side-project
|
|
67
|
+
(no status set)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Only the exceptional states are labelled: an active, notifiable workspace says
|
|
71
|
+
nothing, so the one that differs stands out. `[dnd until ...]` covers both a
|
|
72
|
+
manual snooze and the configured DND hours — from the outside they are the same
|
|
73
|
+
thing — and `slk dnd` breaks out which.
|
|
74
|
+
|
|
75
|
+
Each of those is a separate call, so `slk status` costs up to four per
|
|
76
|
+
workspace. `--no-scheduled` drops the schedule lookup (the one internal
|
|
77
|
+
endpoint), `--brief` drops all three, and a lookup that fails warns and leaves
|
|
78
|
+
that part out rather than failing the command.
|
|
79
|
+
|
|
80
|
+
#### JSON output
|
|
81
|
+
|
|
82
|
+
`slk status --json` (and `slk status scheduled --json`) prints one entry per
|
|
83
|
+
workspace — always an array, even for a single workspace, so a script that did
|
|
84
|
+
not pass `-w` can still parse it. `null` means *not checked* (skipped by a flag,
|
|
85
|
+
or the lookup failed), which is not the same as checked-and-empty: a statusline
|
|
86
|
+
that read a failed DND lookup as "DND off" would say the opposite of the truth.
|
|
87
|
+
Every timestamp appears twice, as Slack's epoch and as ISO 8601:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# "away, quiet until 3:00pm"
|
|
91
|
+
slk status -w work --json --no-scheduled |
|
|
92
|
+
jq -r '.[0] | "\(.presence.presence)\(if .dnd.active then ", quiet until " + (.dnd.until | strflocaltime("%-I:%M%p")) else "" end)"'
|
|
93
|
+
```
|
|
94
|
+
|
|
55
95
|
Statuses can also be scheduled to turn on later (Slack allows up to 5 at a time):
|
|
56
96
|
|
|
57
97
|
```bash
|
|
@@ -170,6 +210,36 @@ slk cache populate # Pre-populate user cache
|
|
|
170
210
|
slk cache clear # Clear all caches
|
|
171
211
|
```
|
|
172
212
|
|
|
213
|
+
### Deactivations
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
slk deactivations # 25 most recent departures
|
|
217
|
+
slk deactivations 90d # Everyone who left in the last 90 days
|
|
218
|
+
slk deactivations 2026-01-01 -n 0 # All departures this year
|
|
219
|
+
slk deactivations --chart # Departures per month
|
|
220
|
+
slk deactivations --grep engineer # Filter by name, handle, title, email, or ID
|
|
221
|
+
slk deactivations --bots # Include deactivated bots and app users
|
|
222
|
+
slk deactivations --json # Machine-readable output
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
acme: 16 deactivated since 30d (664 active members)
|
|
227
|
+
|
|
228
|
+
2026-09-14 Dana Whitfield Platform Support
|
|
229
|
+
2026-09-11 Priya Raghunathan UX Researcher
|
|
230
|
+
2026-09-09 Sam Okonkwo Senior Software Engineer
|
|
231
|
+
|
|
232
|
+
roster cached 5m ago; --refresh to update
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Dates come from each account's `updated` field — the last change Slack recorded
|
|
236
|
+
for that user. For a deactivated account that change is almost always the
|
|
237
|
+
deactivation, but an admin editing a departed profile afterwards moves the date
|
|
238
|
+
forward, so treat it as "last touched" rather than a payroll record.
|
|
239
|
+
|
|
240
|
+
The roster costs one API call per 1000 members, so the result is cached for six
|
|
241
|
+
hours; `--refresh` re-fetches it.
|
|
242
|
+
|
|
173
243
|
### Global Options
|
|
174
244
|
|
|
175
245
|
```bash
|
data/lib/slk/api/users.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Slk
|
|
4
4
|
module Api
|
|
5
5
|
# Wrapper for Slack users.* API endpoints
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
6
7
|
class Users
|
|
7
8
|
def initialize(api_client, workspace, on_debug: nil)
|
|
8
9
|
@api = api_client
|
|
@@ -63,6 +64,31 @@ module Slk
|
|
|
63
64
|
@api.post(@workspace, 'users.list', params)
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
# Page through users.list until the cursor runs out. Yields the running
|
|
68
|
+
# total after each page, for progress and debug output.
|
|
69
|
+
def list_all(limit: 1000, &progress)
|
|
70
|
+
members = []
|
|
71
|
+
cursor = nil
|
|
72
|
+
loop do
|
|
73
|
+
response = list(cursor: cursor, limit: limit)
|
|
74
|
+
members.concat(response['members'] || [])
|
|
75
|
+
progress&.call(members.size)
|
|
76
|
+
cursor = next_cursor(response, cursor)
|
|
77
|
+
break members unless cursor
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Slack ends the roster with an empty cursor. A cursor that comes back
|
|
82
|
+
# unchanged never will, and paging on it spins forever against a remote
|
|
83
|
+
# API — better to fail with the reason than to hang holding a terminal.
|
|
84
|
+
def next_cursor(response, previous)
|
|
85
|
+
cursor = response.dig('response_metadata', 'next_cursor').to_s
|
|
86
|
+
return nil if cursor.empty?
|
|
87
|
+
raise ApiError.new('users.list returned a repeating cursor', code: :invalid_cursor) if cursor == previous
|
|
88
|
+
|
|
89
|
+
cursor
|
|
90
|
+
end
|
|
91
|
+
|
|
66
92
|
def info(user_id)
|
|
67
93
|
@api.post_form(@workspace, 'users.info', { user: user_id })
|
|
68
94
|
end
|
|
@@ -111,5 +137,6 @@ module Slk
|
|
|
111
137
|
@api.post_form(@workspace, 'users.conversations', params)
|
|
112
138
|
end
|
|
113
139
|
end
|
|
140
|
+
# rubocop:enable Metrics/ClassLength
|
|
114
141
|
end
|
|
115
142
|
end
|
data/lib/slk/cli.rb
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# Show who left the workspace and when, derived from users.list.
|
|
6
|
+
# Examples:
|
|
7
|
+
# slk deactivations # 25 most recent departures
|
|
8
|
+
# slk deactivations 90d # everyone who left in the last 90 days
|
|
9
|
+
# slk deactivations --chart # departures per month
|
|
10
|
+
# slk deactivations --grep engineer # filter by name, handle, title, email, ID
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
|
+
class Deactivations < Base
|
|
13
|
+
DEFAULT_LIMIT = 25
|
|
14
|
+
CHART_MONTHS = 12
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
result = validate_options
|
|
18
|
+
return result if result
|
|
19
|
+
|
|
20
|
+
run
|
|
21
|
+
rescue ApiError => e
|
|
22
|
+
error("API error: #{e.message}")
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def handle_option(arg, args, _remaining)
|
|
29
|
+
case arg
|
|
30
|
+
when '-n', '--limit' then @options[:limit] = parse_limit(arg, option_value(arg, args))
|
|
31
|
+
when '--since' then @options[:since] = option_value(arg, args)
|
|
32
|
+
when '--chart' then @options[:chart] = true
|
|
33
|
+
when '--bots' then @options[:bots] = true
|
|
34
|
+
when '--grep' then @options[:grep] = option_value(arg, args)
|
|
35
|
+
when '--refresh', '--no-cache' then @options[:refresh] = true
|
|
36
|
+
else return super
|
|
37
|
+
end
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def help_text
|
|
42
|
+
help = Support::HelpFormatter.new('slk deactivations [since] [options]')
|
|
43
|
+
help.description('Show deactivated accounts — who left the workspace, and when.')
|
|
44
|
+
help.note("Dates come from each account's `updated` field, which for a deactivated")
|
|
45
|
+
help.note('account is the deactivation itself unless an admin edited the profile after.')
|
|
46
|
+
add_options_section(help)
|
|
47
|
+
add_examples_section(help)
|
|
48
|
+
help.render
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Base defaults to 72 columns for prose wrapping; this is a table, so use
|
|
54
|
+
# the whole terminal and let long titles keep their tail. A tty that
|
|
55
|
+
# refuses to report its size (some Windows consoles, some CI shims) is
|
|
56
|
+
# not a reason to fail before the command has even parsed its arguments.
|
|
57
|
+
def default_width
|
|
58
|
+
return 100 unless $stdout.tty?
|
|
59
|
+
|
|
60
|
+
IO.console&.winsize&.last || 100
|
|
61
|
+
rescue Errno::ENOTTY, Errno::EINVAL, Errno::ENODEV, IOError, NotImplementedError
|
|
62
|
+
100
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `-n foo` used to reach to_i, become 0, and quietly mean "no limit" —
|
|
66
|
+
# the opposite of asking for fewer rows.
|
|
67
|
+
def parse_limit(flag, value)
|
|
68
|
+
limit = Integer(value, exception: false)
|
|
69
|
+
return limit if limit && !limit.negative?
|
|
70
|
+
|
|
71
|
+
raise UsageError, "#{flag} expects a non-negative integer (got #{value.inspect})."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_options_section(help)
|
|
75
|
+
help.section('OPTIONS') do |s|
|
|
76
|
+
s.option('-n, --limit N', "Rows to show (default #{DEFAULT_LIMIT}, 0 for all)")
|
|
77
|
+
s.option('--since SPEC', 'Only departures since 7d, 4w, 6m, or YYYY-MM-DD')
|
|
78
|
+
s.option('--chart', 'Histogram of departures per month')
|
|
79
|
+
s.option('--grep PATTERN', 'Filter by name, handle, title, email, or user ID')
|
|
80
|
+
s.option('--bots', 'Include deactivated bots and app users')
|
|
81
|
+
s.option('--refresh', 'Re-fetch the roster instead of using the cache')
|
|
82
|
+
s.option('--json', 'Raw JSON output')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def add_examples_section(help)
|
|
87
|
+
help.section('EXAMPLES') do |s|
|
|
88
|
+
s.example('slk deactivations', 'Most recent departures')
|
|
89
|
+
s.example('slk deactivations 90d', 'Everyone who left in the last 90 days')
|
|
90
|
+
s.example('slk deactivations --chart', 'Departures per month')
|
|
91
|
+
s.example('slk deactivations 2026-01-01 -n 0', 'All departures this year')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def run
|
|
96
|
+
workspace = runner.workspace(@options[:workspace])
|
|
97
|
+
@since_label = since_spec
|
|
98
|
+
@since = parse_since(@since_label)
|
|
99
|
+
report = scan(workspace)
|
|
100
|
+
records = collect_records(report)
|
|
101
|
+
|
|
102
|
+
return render_json(workspace, report, records) if @options[:json]
|
|
103
|
+
|
|
104
|
+
render(workspace, report, records)
|
|
105
|
+
0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One window, or none. A second date is a different question, and
|
|
109
|
+
# answering the first one silently is how you misread the answer.
|
|
110
|
+
def since_spec
|
|
111
|
+
extra = positional_args[1..]
|
|
112
|
+
raise UsageError, "Unexpected argument: #{extra.first}. Only one time window is accepted." if extra&.any?
|
|
113
|
+
|
|
114
|
+
@options[:since] || positional_args.first
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def collect_records(report)
|
|
118
|
+
records = filter(report.records)
|
|
119
|
+
@options[:chart] && @since.nil? ? last_year(records) : records
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def render_json(workspace, report, records)
|
|
123
|
+
output_json(json_payload(workspace, report, records))
|
|
124
|
+
0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def scan(workspace)
|
|
128
|
+
Services::DeactivationScanner.new(
|
|
129
|
+
users_api: runner.users_api(workspace.name),
|
|
130
|
+
workspace_name: workspace.name,
|
|
131
|
+
cache_store: cache_store,
|
|
132
|
+
on_debug: ->(msg) { output.debug(msg) }
|
|
133
|
+
).scan(refresh: @options[:refresh])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Filters compose: --bots, --since, --grep all narrow the same list.
|
|
137
|
+
# Records Slack never dated cannot answer a question about a window, so
|
|
138
|
+
# they drop out of one — but they are counted, not silently discarded.
|
|
139
|
+
def filter(records)
|
|
140
|
+
records = records.reject(&:bot) unless @options[:bots]
|
|
141
|
+
pattern = grep_pattern
|
|
142
|
+
records = records.select { |r| r.matches?(pattern) } if pattern
|
|
143
|
+
@undated = records.count { |r| r.deactivated_at.nil? }
|
|
144
|
+
reject_before(records, @since)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def reject_before(records, cutoff)
|
|
148
|
+
return records unless cutoff
|
|
149
|
+
|
|
150
|
+
records.select { |r| r.deactivated_at && r.deactivated_at >= cutoff }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# An all-time histogram of a decade-old workspace is mostly scrollback.
|
|
154
|
+
# Counting in months rather than in 31-day steps keeps the window exactly
|
|
155
|
+
# as long as the label claims.
|
|
156
|
+
def last_year(records)
|
|
157
|
+
reject_before(records, last_year_cutoff)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def last_year_cutoff
|
|
161
|
+
now = Time.now
|
|
162
|
+
index = (now.year * 12) + (now.month - 1) - (CHART_MONTHS - 1)
|
|
163
|
+
Time.new(index / 12, (index % 12) + 1, 1).to_i
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The chart spans the window that was asked for, not merely the months
|
|
167
|
+
# that happen to contain a departure: a quiet opening month is the
|
|
168
|
+
# answer to "how bad is it lately", and dropping it flatters the trend.
|
|
169
|
+
def chart_bounds
|
|
170
|
+
{
|
|
171
|
+
from: Time.at(@since || last_year_cutoff).strftime('%Y-%m'),
|
|
172
|
+
to: Time.now.strftime('%Y-%m')
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_since(spec)
|
|
177
|
+
return nil unless spec
|
|
178
|
+
|
|
179
|
+
Support::DateParser.parse(spec)
|
|
180
|
+
rescue ArgumentError => e
|
|
181
|
+
raise UsageError, e.message
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def grep_pattern
|
|
185
|
+
return nil unless @options[:grep]
|
|
186
|
+
|
|
187
|
+
Regexp.new(@options[:grep], Regexp::IGNORECASE)
|
|
188
|
+
rescue RegexpError => e
|
|
189
|
+
raise UsageError, "Invalid --grep pattern: #{e.message}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def render(workspace, report, records)
|
|
193
|
+
formatter = Formatters::DeactivationFormatter.new(output: output, width: @options[:width])
|
|
194
|
+
formatter.summary(summary_line(workspace, report, records))
|
|
195
|
+
render_body(formatter, records)
|
|
196
|
+
footer = footer(report, records)
|
|
197
|
+
return if footer.empty?
|
|
198
|
+
|
|
199
|
+
puts
|
|
200
|
+
formatter.note(footer)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Even an empty result keeps its footer: "nobody matched" is worth much
|
|
204
|
+
# less without how old the roster behind it is.
|
|
205
|
+
def render_body(formatter, records)
|
|
206
|
+
return info('No deactivations match.') if records.empty?
|
|
207
|
+
|
|
208
|
+
puts
|
|
209
|
+
@options[:chart] ? formatter.chart(records, **chart_bounds) : render_list(formatter, records)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def render_list(formatter, records)
|
|
213
|
+
limit = @options[:limit] || DEFAULT_LIMIT
|
|
214
|
+
shown = limit.positive? ? records.first(limit) : records
|
|
215
|
+
formatter.list(shown)
|
|
216
|
+
return unless shown.size < records.size
|
|
217
|
+
|
|
218
|
+
puts
|
|
219
|
+
formatter.note("Showing #{shown.size} of #{records.size} — use -n 0 to see them all.")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def summary_line(workspace, report, records)
|
|
223
|
+
"#{workspace.name}: #{records.size} #{scope_phrase} " \
|
|
224
|
+
"(#{report.active_count} active members)"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def scope_phrase
|
|
228
|
+
return "deactivated since #{@since_label}" if @since_label
|
|
229
|
+
|
|
230
|
+
@options[:chart] ? "deactivated in the last #{CHART_MONTHS} months" : 'deactivated accounts'
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def footer(report, records)
|
|
234
|
+
total = total_deactivated(report)
|
|
235
|
+
age = cache_age(report)
|
|
236
|
+
parts = []
|
|
237
|
+
parts << "#{total} deactivated in all (of #{report.human_count} accounts ever created)" if records.size < total
|
|
238
|
+
parts << undated_note if undated_note
|
|
239
|
+
parts << "roster cached #{age} ago; --refresh to update" if age
|
|
240
|
+
parts.join(' · ')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Only worth saying when a window was applied: without one nothing was
|
|
244
|
+
# dropped for want of a date.
|
|
245
|
+
def undated_note
|
|
246
|
+
return nil unless windowed? && @undated.to_i.positive?
|
|
247
|
+
|
|
248
|
+
"#{@undated} with no recorded date omitted"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def windowed?
|
|
252
|
+
!@since.nil? || @options[:chart]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def total_deactivated(report)
|
|
256
|
+
return report.deactivated_count if @options[:bots]
|
|
257
|
+
|
|
258
|
+
report.records.count { |r| !r.bot }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def cache_age(report)
|
|
262
|
+
return nil unless report.fetched_at
|
|
263
|
+
|
|
264
|
+
seconds = Time.now.to_i - report.fetched_at
|
|
265
|
+
return nil if seconds < 60
|
|
266
|
+
|
|
267
|
+
Models::Duration.new(seconds: seconds).to_s
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def json_payload(workspace, report, records)
|
|
271
|
+
{
|
|
272
|
+
workspace: workspace.name,
|
|
273
|
+
fetched_at: report.fetched_at,
|
|
274
|
+
active_members: report.active_count,
|
|
275
|
+
accounts_ever: report.human_count,
|
|
276
|
+
total_deactivated: total_deactivated(report),
|
|
277
|
+
includes_bots: @options[:bots] ? true : false,
|
|
278
|
+
matched: records.size,
|
|
279
|
+
deactivations: records.map { |r| r.to_h.merge(deactivated_on: r.date) }
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
# rubocop:enable Metrics/ClassLength
|
|
284
|
+
end
|
|
285
|
+
end
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -4,6 +4,26 @@ module Slk
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Displays help information for commands
|
|
6
6
|
class Help < Base
|
|
7
|
+
# Names are padded to the longest one rather than by hand, so adding a
|
|
8
|
+
# command cannot quietly break the column for every line below it.
|
|
9
|
+
COMMAND_SUMMARIES = [
|
|
10
|
+
['status', 'Get or set your status'],
|
|
11
|
+
['presence', 'Get or set your presence (away/active)'],
|
|
12
|
+
['dnd', 'Manage Do Not Disturb'],
|
|
13
|
+
['messages', 'Read channel or DM messages'],
|
|
14
|
+
['search', 'Search messages across channels'],
|
|
15
|
+
['unread', 'View and clear unread messages'],
|
|
16
|
+
['activity', 'Show activity feed (reactions, mentions, threads)'],
|
|
17
|
+
['later', 'Show saved "Later" items'],
|
|
18
|
+
['who', 'Show a user profile'],
|
|
19
|
+
['deactivations', 'Show who left the workspace, and when'],
|
|
20
|
+
['preset', 'Manage and apply status presets'],
|
|
21
|
+
['workspaces', 'Manage Slack workspaces'],
|
|
22
|
+
['cache', 'Manage user/channel cache'],
|
|
23
|
+
['emoji', 'Download workspace custom emoji'],
|
|
24
|
+
['config', 'Configuration and setup']
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
7
27
|
def execute
|
|
8
28
|
topic = positional_args.first
|
|
9
29
|
|
|
@@ -35,26 +55,13 @@ module Slk
|
|
|
35
55
|
HEADER
|
|
36
56
|
end
|
|
37
57
|
|
|
38
|
-
# rubocop:disable Metrics/AbcSize
|
|
39
58
|
def build_commands_section
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
#{output.cyan('messages')} Read channel or DM messages
|
|
46
|
-
#{output.cyan('search')} Search messages across channels
|
|
47
|
-
#{output.cyan('unread')} View and clear unread messages
|
|
48
|
-
#{output.cyan('activity')} Show activity feed (reactions, mentions, threads)
|
|
49
|
-
#{output.cyan('later')} Show saved "Later" items
|
|
50
|
-
#{output.cyan('preset')} Manage and apply status presets
|
|
51
|
-
#{output.cyan('workspaces')} Manage Slack workspaces
|
|
52
|
-
#{output.cyan('cache')} Manage user/channel cache
|
|
53
|
-
#{output.cyan('emoji')} Download workspace custom emoji
|
|
54
|
-
#{output.cyan('config')} Configuration and setup
|
|
55
|
-
COMMANDS
|
|
59
|
+
width = COMMAND_SUMMARIES.map { |name, _| name.length }.max + 2
|
|
60
|
+
rows = COMMAND_SUMMARIES.map do |name, summary|
|
|
61
|
+
" #{output.cyan(name)}#{' ' * (width - name.length)}#{summary}"
|
|
62
|
+
end
|
|
63
|
+
"#{output.bold('COMMANDS:')}\n#{rows.join("\n")}\n"
|
|
56
64
|
end
|
|
57
|
-
# rubocop:enable Metrics/AbcSize
|
|
58
65
|
|
|
59
66
|
def build_options_section
|
|
60
67
|
<<~OPTIONS
|