slk 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 +36 -0
- data/README.md +67 -0
- data/lib/slk/api/users.rb +27 -0
- data/lib/slk/cli.rb +2 -1
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +415 -0
- data/lib/slk/commands/help.rb +25 -18
- data/lib/slk/formatters/csv_writer.rb +34 -0
- data/lib/slk/formatters/deactivation_csv.rb +49 -0
- data/lib/slk/formatters/deactivation_formatter.rb +124 -0
- data/lib/slk/formatters/output.rb +39 -0
- data/lib/slk/models/deactivation.rb +77 -0
- data/lib/slk/models/tenure.rb +66 -0
- data/lib/slk/services/api_client.rb +12 -1
- data/lib/slk/services/cache_store.rb +19 -0
- data/lib/slk/services/deactivation_scanner.rb +105 -0
- data/lib/slk/services/meta_cache.rb +16 -1
- data/lib/slk/services/start_date_field.rb +67 -0
- data/lib/slk/services/start_date_lookup.rb +98 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +10 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eec8154512ee9815597065ac68471bfd2ad55535191d4057e55ae4d942f15f66
|
|
4
|
+
data.tar.gz: 3fe2bdbe0fa65ce0b0ee4a529531a38c59f78c674eed08f22d5d40b833b79150
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9a595db19f0748ccb42a4ec436dffa0fe4bbb4d1fbff0514f659cee09a8fb1db2c5258cdd7e74252972c2029e63de0082a2cadf33d96f98dff1681717680e88
|
|
7
|
+
data.tar.gz: 0e33aa2ec6319fe88433dffe30865d0c17306da34cf136aebd1e9b17e9bb42d9bc5447748914751a74e48f43883bfe3cc404fb657cfaf33c2abdf7db369d2e9a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ 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.10.0] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`slk deactivations --tenure`** — how long each person stayed
|
|
13
|
+
- Start dates come from the workspace's "Start Date" custom profile field, discovered from the team schema rather than hardcoded, since every workspace numbers its own fields. A date-typed field wins over a text one with the same label
|
|
14
|
+
- `users.list` does not carry custom fields, so this costs one `users.profile.get` per person, and Slack rate-limits that endpoint to roughly eight calls a minute. The command says how long it will take before it starts, and only looks up the rows it is about to show
|
|
15
|
+
- Every answer is cached the moment it arrives, not at the end, so interrupting a long lookup keeps the work already paid for. Accounts with no start date on file are cached too, otherwise every run would pay again to learn the same nothing
|
|
16
|
+
- Tenure is counted in whole months: somebody who started on the 20th and left on the 3rd has not completed that month. An end date before the start is a data entry error rather than a negative tenure, and reads as blank
|
|
17
|
+
- Blank means nobody filled the field in, and the column disappears entirely when no start date is known. `--tenure` with `--chart` is refused rather than ignored — a histogram has no row to hang a tenure on
|
|
18
|
+
- **`slk deactivations --csv`** — the spreadsheet that always gets asked for
|
|
19
|
+
- Writes every match rather than the screenful `-n` would show: a truncated export is a wrong answer that looks like a right one
|
|
20
|
+
- With `--tenure` it gains `started_on`, `tenure_months` and a readable `tenure` column; unknown start dates leave empty cells rather than zeros, so averaging tenure in a spreadsheet skips them instead of counting people who left the day they arrived
|
|
21
|
+
- RFC 4180 quoting, hand-rolled, because Ruby 3.4 moved csv out of the default gems and this tool ships with no dependencies
|
|
22
|
+
- Progress and warnings go to stderr, so `slk deactivations --csv > file.csv` captures only data
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **A cache that cannot be written no longer costs you the run.** `MetaCache.write` returns disk failures instead of raising them: a full or read-only disk means "no cache", not "no answer". This mattered most for start date lookups, which sit behind minutes of rate-limited calls, but it also fixed the deactivation roster scan, which crashed outright on an unwritable cache directory. Other exceptions still raise — a bug in the value being cached is not a disk problem
|
|
27
|
+
- **A cache that cannot be read no longer crashes.** An unreadable cache file now warns and is skipped, the same way a corrupt one already was. The file is left in place rather than deleted, since removing it needs the access that just failed
|
|
28
|
+
- **A malformed API response fails as an API error.** Slack replying with a JSON array, string or null instead of an object used to surface as `TypeError` or `NoMethodError` from whichever command happened to dig into it first
|
|
29
|
+
- **`slk cache clear` now clears all caches.** It removed only the user and channel caches while reporting "Cleared all caches"; start dates, deactivation rosters and resolved profiles survived it
|
|
30
|
+
|
|
31
|
+
## [0.9.0] - 2026-09-18
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`slk deactivations`** — who left the workspace, and when
|
|
36
|
+
- 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
|
|
37
|
+
- 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
|
|
38
|
+
- `slk deactivations 90d` (or `--since 90d`, `--since 2026-01-01`) narrows to a window; `--grep` filters across name, handle, title, email and user ID
|
|
39
|
+
- `--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
|
|
40
|
+
- 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
|
|
41
|
+
- Bots and app users are excluded from the counts and the list; `--bots` puts them back
|
|
42
|
+
- 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
|
|
43
|
+
|
|
8
44
|
## [0.8.0] - 2026-08-30
|
|
9
45
|
|
|
10
46
|
### Added
|
data/README.md
CHANGED
|
@@ -210,6 +210,73 @@ slk cache populate # Pre-populate user cache
|
|
|
210
210
|
slk cache clear # Clear all caches
|
|
211
211
|
```
|
|
212
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 --tenure # Add how long each person stayed
|
|
221
|
+
slk deactivations 1y --csv # Spreadsheet export of a year of departures
|
|
222
|
+
slk deactivations --grep engineer # Filter by name, handle, title, email, or ID
|
|
223
|
+
slk deactivations --bots # Include deactivated bots and app users
|
|
224
|
+
slk deactivations --json # Machine-readable output
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
acme: 16 deactivated since 30d (664 active members)
|
|
229
|
+
|
|
230
|
+
2026-09-14 Dana Whitfield Platform Support
|
|
231
|
+
2026-09-11 Priya Raghunathan UX Researcher
|
|
232
|
+
2026-09-09 Sam Okonkwo Senior Software Engineer
|
|
233
|
+
|
|
234
|
+
roster cached 5m ago; --refresh to update
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Dates come from each account's `updated` field — the last change Slack recorded
|
|
238
|
+
for that user. For a deactivated account that change is almost always the
|
|
239
|
+
deactivation, but an admin editing a departed profile afterwards moves the date
|
|
240
|
+
forward, so treat it as "last touched" rather than a payroll record.
|
|
241
|
+
|
|
242
|
+
The roster costs one API call per 1000 members, so the result is cached for six
|
|
243
|
+
hours; `--refresh` re-fetches it.
|
|
244
|
+
|
|
245
|
+
#### Tenure
|
|
246
|
+
|
|
247
|
+
`--tenure` adds how long each person was here, taken from the workspace's
|
|
248
|
+
"Start Date" profile field:
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
acme: 3 deactivated since 30d (664 active members)
|
|
252
|
+
|
|
253
|
+
2026-09-14 Dana Whitfield 1mo Platform Support
|
|
254
|
+
2026-09-11 Priya Raghunathan 2y 7mo UX Researcher
|
|
255
|
+
2026-09-09 Sam Okonkwo 4y 10mo Senior Software Engineer
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This one is slow the first time. Start dates are not in the roster — they cost
|
|
259
|
+
one `users.profile.get` per person, and Slack rate-limits that endpoint to
|
|
260
|
+
roughly eight calls a minute, so a screenful takes about three minutes. The
|
|
261
|
+
command says so before it starts, only looks up the rows it is about to show,
|
|
262
|
+
and caches each answer the moment it arrives: interrupting it keeps the work
|
|
263
|
+
already paid for, and the second run is instant.
|
|
264
|
+
|
|
265
|
+
Blank means nobody filled the field in. Months are whole months, so somebody
|
|
266
|
+
who started on the 20th and left on the 3rd has not completed that month.
|
|
267
|
+
|
|
268
|
+
#### CSV
|
|
269
|
+
|
|
270
|
+
`--csv` writes every match — not just the screenful `-n` would show, since a
|
|
271
|
+
truncated export is a wrong answer that looks like a right one. Combine it with
|
|
272
|
+
`--tenure` for `started_on`, `tenure_months` and a readable `tenure` column:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
slk deactivations 1y --tenure --csv > departures.csv
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Progress and warnings go to stderr, so the redirect above captures only data.
|
|
279
|
+
|
|
213
280
|
### Global Options
|
|
214
281
|
|
|
215
282
|
```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
data/lib/slk/commands/cache.rb
CHANGED
|
@@ -65,6 +65,14 @@ module Slk
|
|
|
65
65
|
0
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# Every kind, including the meta cache behind start dates, deactivation
|
|
69
|
+
# rosters and resolved profiles — "cleared" should not have exceptions.
|
|
70
|
+
def clear_every_cache(workspace_name)
|
|
71
|
+
cache_store.clear_user_cache(*[workspace_name].compact)
|
|
72
|
+
cache_store.clear_channel_cache(*[workspace_name].compact)
|
|
73
|
+
cache_store.clear_meta_cache(*[workspace_name].compact)
|
|
74
|
+
end
|
|
75
|
+
|
|
68
76
|
def display_workspace_status(workspace)
|
|
69
77
|
puts output.bold(workspace.name) if target_workspaces.size > 1
|
|
70
78
|
display_cache_counts(workspace)
|
|
@@ -87,15 +95,8 @@ module Slk
|
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def clear_cache(workspace_name)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
cache_store.clear_channel_cache(workspace_name)
|
|
93
|
-
success("Cleared cache for #{workspace_name}")
|
|
94
|
-
else
|
|
95
|
-
cache_store.clear_user_cache
|
|
96
|
-
cache_store.clear_channel_cache
|
|
97
|
-
success('Cleared all caches')
|
|
98
|
-
end
|
|
98
|
+
clear_every_cache(workspace_name)
|
|
99
|
+
success(workspace_name ? "Cleared cache for #{workspace_name}" : 'Cleared all caches')
|
|
99
100
|
|
|
100
101
|
0
|
|
101
102
|
end
|
|
@@ -0,0 +1,415 @@
|
|
|
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
|
+
# slk deactivations --tenure # add how long each person stayed
|
|
12
|
+
# slk deactivations --csv # spreadsheet export of every match
|
|
13
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
|
+
class Deactivations < Base
|
|
15
|
+
DEFAULT_LIMIT = 25
|
|
16
|
+
CHART_MONTHS = 12
|
|
17
|
+
# Measured against a live workspace: users.profile.get answers two or
|
|
18
|
+
# three calls in a row, then makes you wait out a thirty second
|
|
19
|
+
# Retry-After — about eight lookups a minute averaged over a long run,
|
|
20
|
+
# which is what the time estimate is built on.
|
|
21
|
+
LOOKUPS_PER_MINUTE = 8
|
|
22
|
+
COST_WARNING_AT = 5
|
|
23
|
+
SWITCHES = {
|
|
24
|
+
'--chart' => :chart, '--bots' => :bots, '--tenure' => :tenure, '--csv' => :csv,
|
|
25
|
+
'--refresh' => :refresh, '--no-cache' => :refresh
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def execute
|
|
29
|
+
result = validate_options
|
|
30
|
+
return result if result
|
|
31
|
+
|
|
32
|
+
run
|
|
33
|
+
rescue Services::StartDateLookup::MissingFieldError => e
|
|
34
|
+
error(e.message)
|
|
35
|
+
1
|
|
36
|
+
rescue ApiError => e
|
|
37
|
+
error("API error: #{e.message}")
|
|
38
|
+
1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
protected
|
|
42
|
+
|
|
43
|
+
def handle_option(arg, args, _remaining)
|
|
44
|
+
return switch_on(SWITCHES[arg]) if SWITCHES.key?(arg)
|
|
45
|
+
|
|
46
|
+
case arg
|
|
47
|
+
when '-n', '--limit' then @options[:limit] = parse_limit(arg, option_value(arg, args))
|
|
48
|
+
when '--since' then @options[:since] = option_value(arg, args)
|
|
49
|
+
when '--grep' then @options[:grep] = option_value(arg, args)
|
|
50
|
+
else return super
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def switch_on(key)
|
|
56
|
+
@options[key] = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def help_text
|
|
60
|
+
help = Support::HelpFormatter.new('slk deactivations [since] [options]')
|
|
61
|
+
help.description('Show deactivated accounts — who left the workspace, and when.')
|
|
62
|
+
help.note("Dates come from each account's `updated` field, which for a deactivated")
|
|
63
|
+
help.note('account is the deactivation itself unless an admin edited the profile after.')
|
|
64
|
+
add_options_section(help)
|
|
65
|
+
add_examples_section(help)
|
|
66
|
+
help.render
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Base defaults to 72 columns for prose wrapping; this is a table, so use
|
|
72
|
+
# the whole terminal and let long titles keep their tail. A tty that
|
|
73
|
+
# refuses to report its size (some Windows consoles, some CI shims) is
|
|
74
|
+
# not a reason to fail before the command has even parsed its arguments.
|
|
75
|
+
def default_width
|
|
76
|
+
return 100 unless $stdout.tty?
|
|
77
|
+
|
|
78
|
+
IO.console&.winsize&.last || 100
|
|
79
|
+
rescue Errno::ENOTTY, Errno::EINVAL, Errno::ENODEV, IOError, NotImplementedError
|
|
80
|
+
100
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `-n foo` used to reach to_i, become 0, and quietly mean "no limit" —
|
|
84
|
+
# the opposite of asking for fewer rows.
|
|
85
|
+
def parse_limit(flag, value)
|
|
86
|
+
limit = Integer(value, exception: false)
|
|
87
|
+
return limit if limit && !limit.negative?
|
|
88
|
+
|
|
89
|
+
raise UsageError, "#{flag} expects a non-negative integer (got #{value.inspect})."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def add_options_section(help)
|
|
93
|
+
help.section('OPTIONS') do |s|
|
|
94
|
+
s.option('-n, --limit N', "Rows to show (default #{DEFAULT_LIMIT}, 0 for all)")
|
|
95
|
+
s.option('--since SPEC', 'Only departures since 7d, 4w, 6m, or YYYY-MM-DD')
|
|
96
|
+
s.option('--chart', 'Histogram of departures per month')
|
|
97
|
+
s.option('--tenure', 'Add how long each person stayed (slow: one lookup per person)')
|
|
98
|
+
s.option('--grep PATTERN', 'Filter by name, handle, title, email, or user ID')
|
|
99
|
+
s.option('--bots', 'Include deactivated bots and app users')
|
|
100
|
+
add_output_options(s)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def add_output_options(section)
|
|
105
|
+
section.option('--refresh', 'Re-fetch the roster instead of using the cache')
|
|
106
|
+
section.option('--csv', 'CSV of every match, for a spreadsheet')
|
|
107
|
+
section.option('--json', 'Raw JSON output')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add_examples_section(help)
|
|
111
|
+
help.section('EXAMPLES') do |s|
|
|
112
|
+
s.example('slk deactivations', 'Most recent departures')
|
|
113
|
+
s.example('slk deactivations 90d', 'Everyone who left in the last 90 days')
|
|
114
|
+
s.example('slk deactivations --chart', 'Departures per month')
|
|
115
|
+
s.example('slk deactivations --tenure', 'How long each person stayed')
|
|
116
|
+
s.example('slk deactivations 1y --csv > left.csv', 'Export a year of departures')
|
|
117
|
+
s.example('slk deactivations 2026-01-01 -n 0', 'All departures this year')
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run
|
|
122
|
+
workspace = runner.workspace(@options[:workspace])
|
|
123
|
+
validate_combination
|
|
124
|
+
@since_label = since_spec
|
|
125
|
+
@since = parse_since(@since_label)
|
|
126
|
+
report = scan(workspace)
|
|
127
|
+
records = collect_records(report)
|
|
128
|
+
|
|
129
|
+
emit(workspace, report, records)
|
|
130
|
+
0
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --csv and --json export every match; the terminal list is the only
|
|
134
|
+
# view that pages, so it is the only one -n applies to. (--chart spans
|
|
135
|
+
# its whole window too, for the same reason.)
|
|
136
|
+
def emit(workspace, report, records)
|
|
137
|
+
return render_csv(workspace, records) if @options[:csv]
|
|
138
|
+
return render_json(workspace, report, records) if @options[:json]
|
|
139
|
+
|
|
140
|
+
render(workspace, report, records)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# One window, or none. A second date is a different question, and
|
|
144
|
+
# answering the first one silently is how you misread the answer.
|
|
145
|
+
def since_spec
|
|
146
|
+
extra = positional_args[1..]
|
|
147
|
+
raise UsageError, "Unexpected argument: #{extra.first}. Only one time window is accepted." if extra&.any?
|
|
148
|
+
|
|
149
|
+
@options[:since] || positional_args.first
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A histogram counts departures per month; it has no row to hang a
|
|
153
|
+
# tenure on. Refusing beats quietly ignoring the flag someone paid
|
|
154
|
+
# attention to type.
|
|
155
|
+
# Two ways of asking for the same rows is one too many, and picking a
|
|
156
|
+
# winner silently means the other flag looks broken.
|
|
157
|
+
def validate_combination
|
|
158
|
+
raise UsageError, '--tenure has nothing to add to --chart; drop one of them.' if
|
|
159
|
+
@options[:tenure] && @options[:chart]
|
|
160
|
+
raise UsageError, '--csv and --json are two different exports; pick one.' if
|
|
161
|
+
@options[:csv] && @options[:json]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def collect_records(report)
|
|
165
|
+
records = filter(report.records)
|
|
166
|
+
@options[:chart] && @since.nil? ? last_year(records) : records
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def render_json(workspace, report, records)
|
|
170
|
+
output_json(json_payload(workspace, report, records, tenures(workspace, records)))
|
|
171
|
+
0
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def render_csv(workspace, records)
|
|
175
|
+
Formatters::DeactivationCsv.new(
|
|
176
|
+
output: output, tenures: @options[:tenure] ? tenures(workspace, records) : nil
|
|
177
|
+
).render(records)
|
|
178
|
+
0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Start dates cost one rate-limited call each, so they are only ever
|
|
182
|
+
# fetched for rows that will actually be shown. The caller has already
|
|
183
|
+
# applied -n (or deliberately not, for an export); this memo assumes one
|
|
184
|
+
# record set per run, which is what a single command does.
|
|
185
|
+
def tenures(workspace, records)
|
|
186
|
+
return {} unless @options[:tenure]
|
|
187
|
+
|
|
188
|
+
@tenures ||= resolve_tenures(workspace, records)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def resolve_tenures(workspace, records)
|
|
192
|
+
lookup = start_date_lookup(workspace)
|
|
193
|
+
announce_cost(lookup, records)
|
|
194
|
+
dates = begin
|
|
195
|
+
lookup.fetch(records.map(&:user_id))
|
|
196
|
+
ensure
|
|
197
|
+
# Even when the lookup raises: otherwise the error message arrives
|
|
198
|
+
# glued to a half-drawn "start dates: 12/40".
|
|
199
|
+
output.clear_progress
|
|
200
|
+
end
|
|
201
|
+
report_cache_error(lookup)
|
|
202
|
+
records.to_h { |r| [r.user_id, Models::Tenure.build(dates[r.user_id], r.deactivated_time)] }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The answers still arrived; they just will not be there next time.
|
|
206
|
+
def report_cache_error(lookup)
|
|
207
|
+
return unless lookup.cache_error
|
|
208
|
+
|
|
209
|
+
warn("Could not save the start date cache (#{lookup.cache_error}). " \
|
|
210
|
+
'These lookups will have to be repeated next run.')
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def start_date_lookup(workspace)
|
|
214
|
+
Services::StartDateLookup.new(
|
|
215
|
+
users_api: runner.users_api(workspace.name),
|
|
216
|
+
field: start_date_field(workspace),
|
|
217
|
+
workspace_name: workspace.name,
|
|
218
|
+
cache_store: cache_store,
|
|
219
|
+
on_progress: ->(done, total) { output.progress("start dates: #{done}/#{total}") }
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def start_date_field(workspace)
|
|
224
|
+
Services::StartDateField.new(
|
|
225
|
+
team_api: runner.team_api(workspace.name),
|
|
226
|
+
workspace_name: workspace.name,
|
|
227
|
+
cache_store: cache_store,
|
|
228
|
+
on_debug: ->(msg) { output.debug(msg) }
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Better to say how long this will take than to let someone wonder
|
|
233
|
+
# whether the terminal has hung.
|
|
234
|
+
def announce_cost(lookup, records)
|
|
235
|
+
pending = lookup.uncached_count(records.map(&:user_id))
|
|
236
|
+
return if pending < COST_WARNING_AT
|
|
237
|
+
|
|
238
|
+
minutes = [(pending.to_f / LOOKUPS_PER_MINUTE).round, 1].max
|
|
239
|
+
warn("Looking up #{pending} start dates, one profile call each. Slack rate-limits these " \
|
|
240
|
+
"to about #{LOOKUPS_PER_MINUTE} a minute, so this will take roughly #{minutes} " \
|
|
241
|
+
"minute#{'s' if minutes > 1}. Interrupting is safe: each answer is cached as it arrives.")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def scan(workspace)
|
|
245
|
+
Services::DeactivationScanner.new(
|
|
246
|
+
users_api: runner.users_api(workspace.name),
|
|
247
|
+
workspace_name: workspace.name,
|
|
248
|
+
cache_store: cache_store,
|
|
249
|
+
on_debug: ->(msg) { output.debug(msg) }
|
|
250
|
+
).scan(refresh: @options[:refresh])
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Filters compose: --bots, --since, --grep all narrow the same list.
|
|
254
|
+
# Records Slack never dated cannot answer a question about a window, so
|
|
255
|
+
# they drop out of one — but they are counted, not silently discarded.
|
|
256
|
+
def filter(records)
|
|
257
|
+
records = records.reject(&:bot) unless @options[:bots]
|
|
258
|
+
pattern = grep_pattern
|
|
259
|
+
records = records.select { |r| r.matches?(pattern) } if pattern
|
|
260
|
+
@undated = records.count { |r| r.deactivated_at.nil? }
|
|
261
|
+
reject_before(records, @since)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def reject_before(records, cutoff)
|
|
265
|
+
return records unless cutoff
|
|
266
|
+
|
|
267
|
+
records.select { |r| r.deactivated_at && r.deactivated_at >= cutoff }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# An all-time histogram of a decade-old workspace is mostly scrollback.
|
|
271
|
+
# Counting in months rather than in 31-day steps keeps the window exactly
|
|
272
|
+
# as long as the label claims.
|
|
273
|
+
def last_year(records)
|
|
274
|
+
reject_before(records, last_year_cutoff)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def last_year_cutoff
|
|
278
|
+
now = Time.now
|
|
279
|
+
index = (now.year * 12) + (now.month - 1) - (CHART_MONTHS - 1)
|
|
280
|
+
Time.new(index / 12, (index % 12) + 1, 1).to_i
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The chart spans the window that was asked for, not merely the months
|
|
284
|
+
# that happen to contain a departure: a quiet opening month is the
|
|
285
|
+
# answer to "how bad is it lately", and dropping it flatters the trend.
|
|
286
|
+
def chart_bounds
|
|
287
|
+
{
|
|
288
|
+
from: Time.at(@since || last_year_cutoff).strftime('%Y-%m'),
|
|
289
|
+
to: Time.now.strftime('%Y-%m')
|
|
290
|
+
}
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def parse_since(spec)
|
|
294
|
+
return nil unless spec
|
|
295
|
+
|
|
296
|
+
Support::DateParser.parse(spec)
|
|
297
|
+
rescue ArgumentError => e
|
|
298
|
+
raise UsageError, e.message
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def grep_pattern
|
|
302
|
+
return nil unless @options[:grep]
|
|
303
|
+
|
|
304
|
+
Regexp.new(@options[:grep], Regexp::IGNORECASE)
|
|
305
|
+
rescue RegexpError => e
|
|
306
|
+
raise UsageError, "Invalid --grep pattern: #{e.message}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def render(workspace, report, records)
|
|
310
|
+
formatter = Formatters::DeactivationFormatter.new(output: output, width: @options[:width])
|
|
311
|
+
formatter.summary(summary_line(workspace, report, records))
|
|
312
|
+
render_body(formatter, workspace, records)
|
|
313
|
+
footer = footer(report, records)
|
|
314
|
+
return if footer.empty?
|
|
315
|
+
|
|
316
|
+
puts
|
|
317
|
+
formatter.note(footer)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Even an empty result keeps its footer: "nobody matched" is worth much
|
|
321
|
+
# less without how old the roster behind it is.
|
|
322
|
+
def render_body(formatter, workspace, records)
|
|
323
|
+
return info('No deactivations match.') if records.empty?
|
|
324
|
+
|
|
325
|
+
puts
|
|
326
|
+
return formatter.chart(records, **chart_bounds) if @options[:chart]
|
|
327
|
+
|
|
328
|
+
render_list(formatter, workspace, records)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def render_list(formatter, workspace, records)
|
|
332
|
+
limit = @options[:limit] || DEFAULT_LIMIT
|
|
333
|
+
shown = limit.positive? ? records.first(limit) : records
|
|
334
|
+
formatter.list(shown, tenures: tenures(workspace, shown))
|
|
335
|
+
return unless shown.size < records.size
|
|
336
|
+
|
|
337
|
+
puts
|
|
338
|
+
formatter.note("Showing #{shown.size} of #{records.size} — use -n 0 to see them all.")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def summary_line(workspace, report, records)
|
|
342
|
+
"#{workspace.name}: #{records.size} #{scope_phrase} " \
|
|
343
|
+
"(#{report.active_count} active members)"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def scope_phrase
|
|
347
|
+
return "deactivated since #{@since_label}" if @since_label
|
|
348
|
+
|
|
349
|
+
@options[:chart] ? "deactivated in the last #{CHART_MONTHS} months" : 'deactivated accounts'
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def footer(report, records)
|
|
353
|
+
total = total_deactivated(report)
|
|
354
|
+
age = cache_age(report)
|
|
355
|
+
parts = []
|
|
356
|
+
parts << "#{total} deactivated in all (of #{report.human_count} accounts ever created)" if records.size < total
|
|
357
|
+
parts << undated_note if undated_note
|
|
358
|
+
parts << "roster cached #{age} ago; --refresh to update" if age
|
|
359
|
+
parts.join(' · ')
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Only worth saying when a window was applied: without one nothing was
|
|
363
|
+
# dropped for want of a date.
|
|
364
|
+
def undated_note
|
|
365
|
+
return nil unless windowed? && @undated.to_i.positive?
|
|
366
|
+
|
|
367
|
+
"#{@undated} with no recorded date omitted"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def windowed?
|
|
371
|
+
!@since.nil? || @options[:chart]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def total_deactivated(report)
|
|
375
|
+
return report.deactivated_count if @options[:bots]
|
|
376
|
+
|
|
377
|
+
report.records.count { |r| !r.bot }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def cache_age(report)
|
|
381
|
+
return nil unless report.fetched_at
|
|
382
|
+
|
|
383
|
+
seconds = Time.now.to_i - report.fetched_at
|
|
384
|
+
return nil if seconds < 60
|
|
385
|
+
|
|
386
|
+
Models::Duration.new(seconds: seconds).to_s
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def json_payload(workspace, report, records, tenures = {})
|
|
390
|
+
{
|
|
391
|
+
workspace: workspace.name,
|
|
392
|
+
fetched_at: report.fetched_at,
|
|
393
|
+
active_members: report.active_count,
|
|
394
|
+
accounts_ever: report.human_count,
|
|
395
|
+
total_deactivated: total_deactivated(report),
|
|
396
|
+
includes_bots: @options[:bots] ? true : false,
|
|
397
|
+
matched: records.size,
|
|
398
|
+
deactivations: records.map { |r| json_entry(r, tenures) }
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# started_on and tenure_months appear only when they were asked for:
|
|
403
|
+
# a null that means "not looked up" is indistinguishable from one that
|
|
404
|
+
# means "nobody filled it in".
|
|
405
|
+
def json_entry(record, tenures)
|
|
406
|
+
entry = record.to_h.merge(deactivated_on: record.date)
|
|
407
|
+
return entry unless @options[:tenure]
|
|
408
|
+
|
|
409
|
+
tenure = tenures[record.user_id]
|
|
410
|
+
entry.merge(started_on: tenure&.started, tenure_months: tenure&.months)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
# rubocop:enable Metrics/ClassLength
|
|
414
|
+
end
|
|
415
|
+
end
|