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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae44e63120d4264c9adeb4adb2ea9aba96c4fe9e6087c1134f9bd9d8480a843e
4
- data.tar.gz: '087ea385b7cf62868737d1e583e60ec9fac5aee8bb3d49f711f5c979413db4e7'
3
+ metadata.gz: eec8154512ee9815597065ac68471bfd2ad55535191d4057e55ae4d942f15f66
4
+ data.tar.gz: 3fe2bdbe0fa65ce0b0ee4a529531a38c59f78c674eed08f22d5d40b833b79150
5
5
  SHA512:
6
- metadata.gz: 71be8f27208e35f5dfe81c3504f38f40af7a7630d93802ba56316e07778aa631f7a42ffe1172b9b1c280735871c0667b5b6568a3bb0224642cdabf59523ac8c5
7
- data.tar.gz: 9e4b991d52004df5100fe901e87c1b61f8c50a8fa501665f2cb0a5f58f31a37a6ffae6cbe5c287aac7ae2e99f679cea0889c80866f7980b7f495c7311a507c73
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
@@ -23,7 +23,8 @@ module Slk
23
23
  'help' => Commands::Help,
24
24
  'debug' => Commands::Debug,
25
25
  'who' => Commands::Who,
26
- 'org' => Commands::Org
26
+ 'org' => Commands::Org,
27
+ 'deactivations' => Commands::Deactivations
27
28
  }.freeze
28
29
 
29
30
  def initialize(argv, output: nil)
@@ -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
- if workspace_name
91
- cache_store.clear_user_cache(workspace_name)
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