slk 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae44e63120d4264c9adeb4adb2ea9aba96c4fe9e6087c1134f9bd9d8480a843e
4
- data.tar.gz: '087ea385b7cf62868737d1e583e60ec9fac5aee8bb3d49f711f5c979413db4e7'
3
+ metadata.gz: c56010125fc8e78293e4701ac5619742dda1fecd3e6e06ec98a3eb6afab0dc46
4
+ data.tar.gz: 05fad0411c6b216091bc9dd7cade46a36579bbba951042c61859111c57db9356
5
5
  SHA512:
6
- metadata.gz: 71be8f27208e35f5dfe81c3504f38f40af7a7630d93802ba56316e07778aa631f7a42ffe1172b9b1c280735871c0667b5b6568a3bb0224642cdabf59523ac8c5
7
- data.tar.gz: 9e4b991d52004df5100fe901e87c1b61f8c50a8fa501665f2cb0a5f58f31a37a6ffae6cbe5c287aac7ae2e99f679cea0889c80866f7980b7f495c7311a507c73
6
+ metadata.gz: 2ae292c3c50580e4872ec7a2334052b2495d0df8943341970d98f0a1922ea5b15d9612a0b8998de30bfb100100d3daf0567c9270fafef89529fc88c65f72634d
7
+ data.tar.gz: e86c76ce090e6a622a2122c068fb8a384520ff7b38193613f254f446f4a4165f09c48bcf944b2f3d5a2ad3435a0e71502bda8a6ec1b776b0b1b4bbc1ee240424
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ 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
+
8
21
  ## [0.8.0] - 2026-08-30
9
22
 
10
23
  ### Added
data/README.md CHANGED
@@ -210,6 +210,36 @@ 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 --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
+
213
243
  ### Global Options
214
244
 
215
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
@@ -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)
@@ -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
@@ -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
- <<~COMMANDS
41
- #{output.bold('COMMANDS:')}
42
- #{output.cyan('status')} Get or set your status
43
- #{output.cyan('presence')} Get or set your presence (away/active)
44
- #{output.cyan('dnd')} Manage Do Not Disturb
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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders deactivation lists and per-month histograms.
6
+ class DeactivationFormatter
7
+ DATE_WIDTH = 10
8
+ MAX_NAME_WIDTH = 26
9
+ MIN_TITLE_WIDTH = 12
10
+ MAX_BAR_WIDTH = 48
11
+ BAR_CHARS = '█'
12
+
13
+ def initialize(output:, width: nil)
14
+ @output = output
15
+ @width = width || 100
16
+ end
17
+
18
+ # One line per departure: date, name, title. Full ISO dates on every row
19
+ # (rather than month headings) so the output stays greppable.
20
+ def list(records)
21
+ name_width = name_column_width(records)
22
+ records.each { |record| @output.puts(row(record, name_width)) }
23
+ end
24
+
25
+ def chart(records, from: nil, to: nil)
26
+ counts = monthly_counts(records, from: from, to: to)
27
+ return if counts.empty?
28
+
29
+ peak = counts.values.max
30
+ label_width = counts.values.map { |n| n.to_s.length }.max
31
+ counts.each { |month, count| @output.puts(chart_row(month, count, peak, label_width)) }
32
+ end
33
+
34
+ def summary(text)
35
+ @output.puts(@output.bold(text))
36
+ end
37
+
38
+ def note(text)
39
+ @output.puts(@output.gray(text))
40
+ end
41
+
42
+ # Deactivations per calendar month, oldest first, with empty months
43
+ # filled in — a gap in a histogram should read as zero, not as absence.
44
+ # `from`/`to` widen the span to the window the caller asked about, so a
45
+ # quiet first or last month is shown as quiet rather than dropped.
46
+ def monthly_counts(records, from: nil, to: nil)
47
+ months = records.filter_map(&:month).sort
48
+ first = [from, months.first].compact.min
49
+ last = [to, months.last].compact.max
50
+ return {} if first.nil? || last.nil? || first > last
51
+
52
+ all_months(first, last).to_h do |month|
53
+ [month, months.count(month)]
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def chart_row(month, count, peak, label_width)
60
+ bar = BAR_CHARS * bar_length(count, peak)
61
+ "#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
62
+ end
63
+
64
+ def row(record, name_width)
65
+ date = record.date || 'unknown'
66
+ name = truncate(record.best_name.to_s, name_width).ljust(name_width)
67
+ title = truncate(record.title.to_s, title_width(name_width))
68
+ line = "#{@output.gray(date.ljust(DATE_WIDTH))} #{name}"
69
+ title.empty? ? line : "#{line} #{@output.gray(title)}"
70
+ end
71
+
72
+ def name_column_width(records)
73
+ longest = records.map { |r| r.best_name.to_s.length }.max || 0
74
+ longest.clamp(8, MAX_NAME_WIDTH)
75
+ end
76
+
77
+ def title_width(name_width)
78
+ [@width - DATE_WIDTH - name_width - 4, MIN_TITLE_WIDTH].max
79
+ end
80
+
81
+ # A month nobody left gets no bar at all. Rounding a zero up to one
82
+ # block would draw departures that did not happen.
83
+ def bar_length(count, peak)
84
+ return 0 if peak.zero? || count.zero?
85
+
86
+ available = (@width - 16).clamp(10, MAX_BAR_WIDTH)
87
+ [((count.to_f / peak) * available).round, 1].max
88
+ end
89
+
90
+ def truncate(text, width)
91
+ return text if text.length <= width
92
+
93
+ "#{text[0, width - 1]}…"
94
+ end
95
+
96
+ def all_months(first, last)
97
+ (month_index(first)..month_index(last)).map do |index|
98
+ format('%<year>04d-%<month>02d', year: index / 12, month: (index % 12) + 1)
99
+ end
100
+ end
101
+
102
+ def month_index(month)
103
+ year, mon = month.split('-').map(&:to_i)
104
+ (year * 12) + (mon - 1)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # A deactivated Slack account and when it was deactivated.
6
+ #
7
+ # `deactivated_at` comes from the user object's `updated` field, which is
8
+ # the epoch of the last change to that account. For a deactivated account
9
+ # the deactivation is almost always that last change — but an admin who
10
+ # edits a departed user's profile afterwards moves the timestamp forward,
11
+ # so treat it as "last touched", not a payroll record.
12
+ Deactivation = Data.define(
13
+ :user_id, :handle, :real_name, :title, :email, :deactivated_at, :bot
14
+ ) do
15
+ def self.from_api(member)
16
+ profile = member['profile'] || {}
17
+
18
+ new(
19
+ user_id: member['id'],
20
+ handle: member['name'],
21
+ real_name: profile['real_name'] || member['real_name'],
22
+ title: profile['title'],
23
+ email: profile['email'],
24
+ deactivated_at: positive_int(member['updated']),
25
+ bot: bot?(member)
26
+ )
27
+ end
28
+
29
+ # Rebuild from a JSON round-trip through the meta cache.
30
+ def self.from_cache(hash)
31
+ new(
32
+ user_id: hash['user_id'], handle: hash['handle'], real_name: hash['real_name'],
33
+ title: hash['title'], email: hash['email'],
34
+ deactivated_at: positive_int(hash['deactivated_at']), bot: hash['bot'] ? true : false
35
+ )
36
+ end
37
+
38
+ def self.bot?(member)
39
+ member['is_bot'] || member['is_app_user'] || member['id'] == 'USLACKBOT' ? true : false
40
+ end
41
+
42
+ def self.positive_int(value)
43
+ int = value.to_i
44
+ int.positive? ? int : nil
45
+ end
46
+
47
+ def best_name
48
+ return real_name unless real_name.to_s.empty?
49
+ return handle unless handle.to_s.empty?
50
+
51
+ user_id.to_s
52
+ end
53
+
54
+ def deactivated_time
55
+ deactivated_at && Time.at(deactivated_at)
56
+ end
57
+
58
+ def date
59
+ deactivated_time&.strftime('%Y-%m-%d')
60
+ end
61
+
62
+ def month
63
+ deactivated_time&.strftime('%Y-%m')
64
+ end
65
+
66
+ # Matches the caller's pattern against every field a human would search
67
+ # by. Case sensitivity is the pattern's to declare, not this method's.
68
+ def matches?(pattern)
69
+ [handle, real_name, title, email, user_id].compact.any? { |field| pattern.match?(field) }
70
+ end
71
+
72
+ def to_cache
73
+ to_h.transform_keys(&:to_s)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Slack has no "who left" endpoint. What it does have is users.list, which
6
+ # returns deactivated accounts with `deleted: true` and an `updated` epoch
7
+ # for the last change to the account — and for a deactivated account that
8
+ # change is nearly always the deactivation itself.
9
+ #
10
+ # The full roster is one API call per 1000 members, so the derived result
11
+ # (deactivated members plus headcounts, not the whole roster) is cached in
12
+ # the workspace meta cache with a TTL.
13
+ class DeactivationScanner
14
+ CACHE_KEY = 'deactivations_v1'
15
+ DEFAULT_TTL = 21_600 # 6 hours
16
+ REQUIRED_KEYS = %w[fetched_at member_count human_count active_count records].freeze
17
+
18
+ Report = Data.define(:records, :member_count, :human_count, :active_count, :fetched_at) do
19
+ def deactivated_count = records.size
20
+
21
+ def bots = records.count(&:bot)
22
+ end
23
+
24
+ def initialize(users_api:, workspace_name:, cache_store: nil, ttl: DEFAULT_TTL, on_debug: nil)
25
+ @users_api = users_api
26
+ @workspace_name = workspace_name
27
+ @cache = cache_store
28
+ @ttl = ttl
29
+ @on_debug = on_debug
30
+ end
31
+
32
+ # @return [Report] every deactivated account, newest deactivation first
33
+ def scan(refresh: false)
34
+ build_report(cached(refresh: refresh) || store(collect))
35
+ end
36
+
37
+ private
38
+
39
+ def cached(refresh:)
40
+ return nil if refresh
41
+
42
+ data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: @ttl)
43
+ return data if usable?(data)
44
+
45
+ @on_debug&.call('deactivations cache is unusable; re-fetching the roster') if data
46
+ nil
47
+ end
48
+
49
+ # A truncated or older-shaped entry would otherwise be read field by
50
+ # field into a report of zero active members and zero departures — a
51
+ # confident, wrong answer. Missing anything required means refetch.
52
+ def usable?(data)
53
+ data.is_a?(Hash) && data['records'].is_a?(Array) && REQUIRED_KEYS.all? { |key| data[key] }
54
+ end
55
+
56
+ def store(data)
57
+ MetaCache.write(@cache, @workspace_name, CACHE_KEY, data)
58
+ data
59
+ end
60
+
61
+ def collect
62
+ members = fetch_members
63
+ deleted = members.select { |m| m['deleted'] }
64
+
65
+ counts(members).merge(
66
+ 'fetched_at' => Time.now.to_i,
67
+ 'records' => deleted.map { |m| Models::Deactivation.from_api(m).to_cache }
68
+ )
69
+ end
70
+
71
+ def counts(members)
72
+ humans = members.reject { |m| Models::Deactivation.bot?(m) }
73
+
74
+ {
75
+ 'member_count' => members.size,
76
+ 'human_count' => humans.size,
77
+ 'active_count' => humans.count { |m| !m['deleted'] }
78
+ }
79
+ end
80
+
81
+ def fetch_members
82
+ @users_api.list_all do |total|
83
+ @on_debug&.call("users.list: #{total} members fetched")
84
+ end
85
+ end
86
+
87
+ def build_report(data)
88
+ Report.new(
89
+ records: sorted_records(data['records'] || []),
90
+ member_count: data['member_count'].to_i,
91
+ human_count: data['human_count'].to_i,
92
+ active_count: data['active_count'].to_i,
93
+ fetched_at: data['fetched_at']&.to_i
94
+ )
95
+ end
96
+
97
+ # Newest first; accounts with no usable timestamp sort to the bottom
98
+ # rather than pretending to be from 1970.
99
+ def sorted_records(raw)
100
+ raw.map { |hash| Models::Deactivation.from_cache(hash) }
101
+ .sort_by { |record| -(record.deactivated_at || 0) }
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.8.0'
4
+ VERSION = '0.9.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -74,6 +74,7 @@ module Slk
74
74
  autoload :User, 'slk/models/user'
75
75
  autoload :Channel, 'slk/models/channel'
76
76
  autoload :Preset, 'slk/models/preset'
77
+ autoload :Deactivation, 'slk/models/deactivation'
77
78
  autoload :SearchResult, 'slk/models/search_result'
78
79
  autoload :SavedItem, 'slk/models/saved_item'
79
80
  autoload :Profile, 'slk/models/profile'
@@ -107,6 +108,7 @@ module Slk
107
108
  autoload :ProfileBuilder, 'slk/services/profile_builder'
108
109
  autoload :ProfileResolver, 'slk/services/profile_resolver'
109
110
  autoload :MetaCache, 'slk/services/meta_cache'
111
+ autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
110
112
  end
111
113
 
112
114
  # Output formatters for messages, durations, and emoji
@@ -129,6 +131,7 @@ module Slk
129
131
  autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
130
132
  autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
131
133
  autoload :ProfileRows, 'slk/formatters/profile_rows'
134
+ autoload :DeactivationFormatter, 'slk/formatters/deactivation_formatter'
132
135
  end
133
136
 
134
137
  # CLI commands implementing user-facing functionality
@@ -153,6 +156,7 @@ module Slk
153
156
  autoload :Debug, 'slk/commands/debug'
154
157
  autoload :Who, 'slk/commands/who'
155
158
  autoload :Org, 'slk/commands/org'
159
+ autoload :Deactivations, 'slk/commands/deactivations'
156
160
  end
157
161
 
158
162
  # Thin wrappers around Slack API endpoints
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -45,6 +45,7 @@ files:
45
45
  - lib/slk/commands/cache.rb
46
46
  - lib/slk/commands/catchup.rb
47
47
  - lib/slk/commands/config.rb
48
+ - lib/slk/commands/deactivations.rb
48
49
  - lib/slk/commands/debug.rb
49
50
  - lib/slk/commands/dnd.rb
50
51
  - lib/slk/commands/emoji.rb
@@ -64,6 +65,7 @@ files:
64
65
  - lib/slk/formatters/activity_formatter.rb
65
66
  - lib/slk/formatters/attachment_formatter.rb
66
67
  - lib/slk/formatters/block_formatter.rb
68
+ - lib/slk/formatters/deactivation_formatter.rb
67
69
  - lib/slk/formatters/duration_formatter.rb
68
70
  - lib/slk/formatters/emoji_replacer.rb
69
71
  - lib/slk/formatters/json_message_formatter.rb
@@ -80,6 +82,7 @@ files:
80
82
  - lib/slk/formatters/search_formatter.rb
81
83
  - lib/slk/formatters/text_processor.rb
82
84
  - lib/slk/models/channel.rb
85
+ - lib/slk/models/deactivation.rb
83
86
  - lib/slk/models/dnd_state.rb
84
87
  - lib/slk/models/duration.rb
85
88
  - lib/slk/models/message.rb
@@ -99,6 +102,7 @@ files:
99
102
  - lib/slk/services/api_client.rb
100
103
  - lib/slk/services/cache_store.rb
101
104
  - lib/slk/services/configuration.rb
105
+ - lib/slk/services/deactivation_scanner.rb
102
106
  - lib/slk/services/emoji_downloader.rb
103
107
  - lib/slk/services/emoji_searcher.rb
104
108
  - lib/slk/services/encryption.rb