slk 0.9.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: c56010125fc8e78293e4701ac5619742dda1fecd3e6e06ec98a3eb6afab0dc46
4
- data.tar.gz: 05fad0411c6b216091bc9dd7cade46a36579bbba951042c61859111c57db9356
3
+ metadata.gz: eec8154512ee9815597065ac68471bfd2ad55535191d4057e55ae4d942f15f66
4
+ data.tar.gz: 3fe2bdbe0fa65ce0b0ee4a529531a38c59f78c674eed08f22d5d40b833b79150
5
5
  SHA512:
6
- metadata.gz: 2ae292c3c50580e4872ec7a2334052b2495d0df8943341970d98f0a1922ea5b15d9612a0b8998de30bfb100100d3daf0567c9270fafef89529fc88c65f72634d
7
- data.tar.gz: e86c76ce090e6a622a2122c068fb8a384520ff7b38193613f254f446f4a4165f09c48bcf944b2f3d5a2ad3435a0e71502bda8a6ec1b776b0b1b4bbc1ee240424
6
+ metadata.gz: e9a595db19f0748ccb42a4ec436dffa0fe4bbb4d1fbff0514f659cee09a8fb1db2c5258cdd7e74252972c2029e63de0082a2cadf33d96f98dff1681717680e88
7
+ data.tar.gz: 0e33aa2ec6319fe88433dffe30865d0c17306da34cf136aebd1e9b17e9bb42d9bc5447748914751a74e48f43883bfe3cc404fb657cfaf33c2abdf7db369d2e9a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ 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
+
8
31
  ## [0.9.0] - 2026-09-18
9
32
 
10
33
  ### Added
data/README.md CHANGED
@@ -217,6 +217,8 @@ slk deactivations # 25 most recent departures
217
217
  slk deactivations 90d # Everyone who left in the last 90 days
218
218
  slk deactivations 2026-01-01 -n 0 # All departures this year
219
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
220
222
  slk deactivations --grep engineer # Filter by name, handle, title, email, or ID
221
223
  slk deactivations --bots # Include deactivated bots and app users
222
224
  slk deactivations --json # Machine-readable output
@@ -240,6 +242,41 @@ forward, so treat it as "last touched" rather than a payroll record.
240
242
  The roster costs one API call per 1000 members, so the result is cached for six
241
243
  hours; `--refresh` re-fetches it.
242
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
+
243
280
  ### Global Options
244
281
 
245
282
  ```bash
@@ -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
@@ -8,16 +8,31 @@ module Slk
8
8
  # slk deactivations 90d # everyone who left in the last 90 days
9
9
  # slk deactivations --chart # departures per month
10
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
11
13
  # rubocop:disable Metrics/ClassLength
12
14
  class Deactivations < Base
13
15
  DEFAULT_LIMIT = 25
14
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
15
27
 
16
28
  def execute
17
29
  result = validate_options
18
30
  return result if result
19
31
 
20
32
  run
33
+ rescue Services::StartDateLookup::MissingFieldError => e
34
+ error(e.message)
35
+ 1
21
36
  rescue ApiError => e
22
37
  error("API error: #{e.message}")
23
38
  1
@@ -26,18 +41,21 @@ module Slk
26
41
  protected
27
42
 
28
43
  def handle_option(arg, args, _remaining)
44
+ return switch_on(SWITCHES[arg]) if SWITCHES.key?(arg)
45
+
29
46
  case arg
30
47
  when '-n', '--limit' then @options[:limit] = parse_limit(arg, option_value(arg, args))
31
48
  when '--since' then @options[:since] = option_value(arg, args)
32
- when '--chart' then @options[:chart] = true
33
- when '--bots' then @options[:bots] = true
34
49
  when '--grep' then @options[:grep] = option_value(arg, args)
35
- when '--refresh', '--no-cache' then @options[:refresh] = true
36
50
  else return super
37
51
  end
38
52
  true
39
53
  end
40
54
 
55
+ def switch_on(key)
56
+ @options[key] = true
57
+ end
58
+
41
59
  def help_text
42
60
  help = Support::HelpFormatter.new('slk deactivations [since] [options]')
43
61
  help.description('Show deactivated accounts — who left the workspace, and when.')
@@ -76,33 +94,50 @@ module Slk
76
94
  s.option('-n, --limit N', "Rows to show (default #{DEFAULT_LIMIT}, 0 for all)")
77
95
  s.option('--since SPEC', 'Only departures since 7d, 4w, 6m, or YYYY-MM-DD')
78
96
  s.option('--chart', 'Histogram of departures per month')
97
+ s.option('--tenure', 'Add how long each person stayed (slow: one lookup per person)')
79
98
  s.option('--grep PATTERN', 'Filter by name, handle, title, email, or user ID')
80
99
  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')
100
+ add_output_options(s)
83
101
  end
84
102
  end
85
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
+
86
110
  def add_examples_section(help)
87
111
  help.section('EXAMPLES') do |s|
88
112
  s.example('slk deactivations', 'Most recent departures')
89
113
  s.example('slk deactivations 90d', 'Everyone who left in the last 90 days')
90
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')
91
117
  s.example('slk deactivations 2026-01-01 -n 0', 'All departures this year')
92
118
  end
93
119
  end
94
120
 
95
121
  def run
96
122
  workspace = runner.workspace(@options[:workspace])
123
+ validate_combination
97
124
  @since_label = since_spec
98
125
  @since = parse_since(@since_label)
99
126
  report = scan(workspace)
100
127
  records = collect_records(report)
101
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]
102
138
  return render_json(workspace, report, records) if @options[:json]
103
139
 
104
140
  render(workspace, report, records)
105
- 0
106
141
  end
107
142
 
108
143
  # One window, or none. A second date is a different question, and
@@ -114,16 +149,98 @@ module Slk
114
149
  @options[:since] || positional_args.first
115
150
  end
116
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
+
117
164
  def collect_records(report)
118
165
  records = filter(report.records)
119
166
  @options[:chart] && @since.nil? ? last_year(records) : records
120
167
  end
121
168
 
122
169
  def render_json(workspace, report, records)
123
- output_json(json_payload(workspace, report, records))
170
+ output_json(json_payload(workspace, report, records, tenures(workspace, records)))
124
171
  0
125
172
  end
126
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
+
127
244
  def scan(workspace)
128
245
  Services::DeactivationScanner.new(
129
246
  users_api: runner.users_api(workspace.name),
@@ -192,7 +309,7 @@ module Slk
192
309
  def render(workspace, report, records)
193
310
  formatter = Formatters::DeactivationFormatter.new(output: output, width: @options[:width])
194
311
  formatter.summary(summary_line(workspace, report, records))
195
- render_body(formatter, records)
312
+ render_body(formatter, workspace, records)
196
313
  footer = footer(report, records)
197
314
  return if footer.empty?
198
315
 
@@ -202,17 +319,19 @@ module Slk
202
319
 
203
320
  # Even an empty result keeps its footer: "nobody matched" is worth much
204
321
  # less without how old the roster behind it is.
205
- def render_body(formatter, records)
322
+ def render_body(formatter, workspace, records)
206
323
  return info('No deactivations match.') if records.empty?
207
324
 
208
325
  puts
209
- @options[:chart] ? formatter.chart(records, **chart_bounds) : render_list(formatter, records)
326
+ return formatter.chart(records, **chart_bounds) if @options[:chart]
327
+
328
+ render_list(formatter, workspace, records)
210
329
  end
211
330
 
212
- def render_list(formatter, records)
331
+ def render_list(formatter, workspace, records)
213
332
  limit = @options[:limit] || DEFAULT_LIMIT
214
333
  shown = limit.positive? ? records.first(limit) : records
215
- formatter.list(shown)
334
+ formatter.list(shown, tenures: tenures(workspace, shown))
216
335
  return unless shown.size < records.size
217
336
 
218
337
  puts
@@ -267,7 +386,7 @@ module Slk
267
386
  Models::Duration.new(seconds: seconds).to_s
268
387
  end
269
388
 
270
- def json_payload(workspace, report, records)
389
+ def json_payload(workspace, report, records, tenures = {})
271
390
  {
272
391
  workspace: workspace.name,
273
392
  fetched_at: report.fetched_at,
@@ -276,9 +395,20 @@ module Slk
276
395
  total_deactivated: total_deactivated(report),
277
396
  includes_bots: @options[:bots] ? true : false,
278
397
  matched: records.size,
279
- deactivations: records.map { |r| r.to_h.merge(deactivated_on: r.date) }
398
+ deactivations: records.map { |r| json_entry(r, tenures) }
280
399
  }
281
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
282
412
  end
283
413
  # rubocop:enable Metrics/ClassLength
284
414
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # RFC 4180 CSV, hand-rolled: Ruby 3.4 moved csv out of the default gems
6
+ # and into the bundled ones, so requiring it would make this tool depend
7
+ # on a gem, and it ships with none.
8
+ #
9
+ # Quotes only the fields that need it, so the common row stays readable in
10
+ # a terminal as well as in a spreadsheet.
11
+ module CsvWriter
12
+ module_function
13
+
14
+ NEEDS_QUOTES = /[",\r\n]|\A\s|\s\z/
15
+
16
+ def row(values)
17
+ values.map { |value| escape(value) }.join(',')
18
+ end
19
+
20
+ def escape(value)
21
+ text = stringify(value)
22
+ return text unless NEEDS_QUOTES.match?(text)
23
+
24
+ %("#{text.gsub('"', '""')}")
25
+ end
26
+
27
+ # nil is an empty cell. Writing the literal "nil" would give a
28
+ # spreadsheet a four-character string to count, sort and average.
29
+ def stringify(value)
30
+ value.nil? ? '' : value.to_s
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # CSV export of a deactivation list, for the spreadsheet that inevitably
6
+ # gets asked for. Every matched row is written, not just the screenful a
7
+ # terminal would show — a truncated export is a wrong answer that looks
8
+ # like a right one.
9
+ class DeactivationCsv
10
+ HEADERS = %w[deactivated_on user_id handle real_name title email bot].freeze
11
+ TENURE_HEADERS = %w[started_on tenure_months tenure].freeze
12
+
13
+ def initialize(output:, tenures: nil)
14
+ @output = output
15
+ @tenures = tenures
16
+ end
17
+
18
+ def render(records)
19
+ @output.puts(CsvWriter.row(headers))
20
+ records.each { |record| @output.puts(CsvWriter.row(cells(record))) }
21
+ end
22
+
23
+ private
24
+
25
+ def headers
26
+ @tenures ? HEADERS + TENURE_HEADERS : HEADERS
27
+ end
28
+
29
+ def cells(record)
30
+ row = [record.date, record.user_id, record.handle, record.real_name,
31
+ record.title, record.email, record.bot]
32
+ @tenures ? row + tenure_cells(record) : row
33
+ end
34
+
35
+ # Empty cells rather than zeros, so a spreadsheet averaging tenure skips
36
+ # them instead of counting people who left the day they arrived. Two
37
+ # different unknowns land here: no start date on file at all, which
38
+ # blanks the whole group, and a start date later than the departure,
39
+ # which keeps started_on so the bad data is visible and blanks the
40
+ # length that cannot be derived from it.
41
+ def tenure_cells(record)
42
+ tenure = @tenures[record.user_id]
43
+ return [nil, nil, nil] unless tenure
44
+
45
+ [tenure.started, tenure.months, tenure.to_s]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -15,11 +15,16 @@ module Slk
15
15
  @width = width || 100
16
16
  end
17
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)
18
+ # One line per departure: date, name, tenure, title. Full ISO dates on
19
+ # every row (rather than month headings) so the output stays greppable.
20
+ #
21
+ # `tenures` is keyed by user ID and may cover only some of the rows: a
22
+ # start date nobody filled in leaves the column blank rather than
23
+ # guessing, and the column disappears entirely when none are known.
24
+ def list(records, tenures: {})
21
25
  name_width = name_column_width(records)
22
- records.each { |record| @output.puts(row(record, name_width)) }
26
+ tenure_width = tenure_column_width(records, tenures)
27
+ records.each { |record| @output.puts(row(record, name_width, tenures, tenure_width)) }
23
28
  end
24
29
 
25
30
  def chart(records, from: nil, to: nil)
@@ -61,21 +66,32 @@ module Slk
61
66
  "#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
62
67
  end
63
68
 
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
+ def row(record, name_width, tenures = {}, tenure_width = 0)
70
+ line = "#{@output.gray((record.date || 'unknown').ljust(DATE_WIDTH))} " \
71
+ "#{truncate(record.best_name.to_s, name_width).ljust(name_width)}"
72
+ line = "#{line} #{@output.gray(tenure_cell(record, tenures, tenure_width))}" if tenure_width.positive?
73
+ title = truncate(record.title.to_s, title_width(name_width, tenure_width))
69
74
  title.empty? ? line : "#{line} #{@output.gray(title)}"
70
75
  end
71
76
 
77
+ def tenure_cell(record, tenures, width)
78
+ tenures[record.user_id].to_s.rjust(width)
79
+ end
80
+
81
+ def tenure_column_width(records, tenures)
82
+ return 0 if tenures.nil? || tenures.empty?
83
+
84
+ records.filter_map { |r| tenures[r.user_id]&.to_s&.length }.max || 0
85
+ end
86
+
72
87
  def name_column_width(records)
73
88
  longest = records.map { |r| r.best_name.to_s.length }.max || 0
74
89
  longest.clamp(8, MAX_NAME_WIDTH)
75
90
  end
76
91
 
77
- def title_width(name_width)
78
- [@width - DATE_WIDTH - name_width - 4, MIN_TITLE_WIDTH].max
92
+ def title_width(name_width, tenure_width = 0)
93
+ tenure_space = tenure_width.positive? ? tenure_width + 2 : 0
94
+ [@width - DATE_WIDTH - name_width - tenure_space - 4, MIN_TITLE_WIDTH].max
79
95
  end
80
96
 
81
97
  # A month nobody left gets no bar at all. Rounding a zero up to one
@@ -26,6 +26,7 @@ module Slk
26
26
  @color = color.nil? ? io.tty? : color
27
27
  @verbose = verbose
28
28
  @quiet = quiet
29
+ @last_progress_width = nil
29
30
  end
30
31
 
31
32
  def puts(message = '')
@@ -52,6 +53,44 @@ module Slk
52
53
  puts(colorize(message))
53
54
  end
54
55
 
56
+ # Transient progress on stderr: it never pollutes piped stdout, and it
57
+ # overwrites itself rather than scrolling. Silent under --quiet, and
58
+ # when stderr is not a terminal, since a log file full of half-drawn
59
+ # counters helps nobody.
60
+ def progress(message)
61
+ return unless progress?
62
+
63
+ @last_progress_width = message.length
64
+ write_progress("\r#{message}")
65
+ end
66
+
67
+ # Erases whatever progress() last drew. Keyed off the saved width rather
68
+ # than re-checking tty state: if a line was drawn, it gets cleaned up.
69
+ def clear_progress
70
+ return unless @last_progress_width
71
+
72
+ write_progress("\r#{' ' * @last_progress_width}\r")
73
+ @last_progress_width = nil
74
+ end
75
+
76
+ # This is decoration. It is often called from an ensure block cleaning
77
+ # up after a real failure, and a closed or broken stderr must not
78
+ # replace that failure with one about drawing a counter.
79
+ def write_progress(text)
80
+ @err.print(text)
81
+ @err.flush
82
+ rescue SystemCallError, IOError
83
+ nil
84
+ end
85
+
86
+ def progress? = tty_err? && !@quiet
87
+
88
+ def tty_err?
89
+ @err.tty?
90
+ rescue SystemCallError, IOError
91
+ false
92
+ end
93
+
55
94
  def debug(message)
56
95
  return unless @verbose
57
96
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # How long someone was here: a start date from their Slack profile and the
6
+ # date their account was deactivated.
7
+ #
8
+ # Both ends are softer than they look. The start date is whatever an admin
9
+ # typed into a custom profile field, and the end is the account's `updated`
10
+ # timestamp. Neither is a payroll record, so this rounds to whole months
11
+ # and refuses to imply a precision it does not have.
12
+ Tenure = Data.define(:started_on, :ended_on) do
13
+ # @param started [String, nil] ISO date from the profile field
14
+ # @param ended [Time, nil] deactivation time
15
+ def self.build(started, ended)
16
+ date = parse_date(started)
17
+ return nil unless date
18
+
19
+ new(started_on: date, ended_on: ended ? to_date(ended) : nil)
20
+ end
21
+
22
+ def self.parse_date(value)
23
+ text = value.to_s.strip
24
+ return nil unless /\A\d{4}-\d{2}-\d{2}\z/.match?(text)
25
+
26
+ Date.iso8601(text)
27
+ rescue Date::Error
28
+ nil
29
+ end
30
+
31
+ def self.to_date(time)
32
+ Date.new(time.year, time.month, time.day)
33
+ end
34
+
35
+ # Nil when the account is still active, or when the end predates the
36
+ # start — a start date typed in after the fact can land anywhere, and a
37
+ # negative tenure is a data entry error, not a fact about a person.
38
+ def months
39
+ return nil unless ended_on && ended_on >= started_on
40
+
41
+ ended_on.day < started_on.day ? month_span - 1 : month_span
42
+ end
43
+
44
+ def month_span
45
+ ((ended_on.year - started_on.year) * 12) + (ended_on.month - started_on.month)
46
+ end
47
+ private :month_span
48
+
49
+ # "6y 2mo", "11mo", "<1mo" — whole months only.
50
+ def to_s
51
+ total = months
52
+ return '' unless total
53
+ return '<1mo' if total.zero?
54
+
55
+ years, rest = total.divmod(12)
56
+ [years.positive? ? "#{years}y" : nil, rest.positive? ? "#{rest}mo" : nil].compact.join(' ')
57
+ end
58
+
59
+ # True when the length cannot be worked out: an active account, or an
60
+ # end date that predates the start.
61
+ def unknown? = months.nil?
62
+
63
+ def started = started_on.iso8601
64
+ end
65
+ end
66
+ end
@@ -232,7 +232,7 @@ module Slk
232
232
  end
233
233
 
234
234
  def parse_success_response(response)
235
- result = JSON.parse(response.body)
235
+ result = parse_body(response.body)
236
236
  raise_rate_limit(response) if result['error'] == 'ratelimited'
237
237
  unless result['ok']
238
238
  message = result['error'] || 'Unknown error'
@@ -240,6 +240,17 @@ module Slk
240
240
  end
241
241
 
242
242
  result
243
+ end
244
+
245
+ # Every caller treats a response as a Hash and digs into it. Slack
246
+ # sending a bare array, string or null is not something any of them can
247
+ # act on, so it fails here as an API error rather than as a TypeError
248
+ # somewhere downstream.
249
+ def parse_body(body)
250
+ result = JSON.parse(body)
251
+ return result if result.is_a?(Hash)
252
+
253
+ raise ApiError.new('Unexpected response shape from Slack API', code: :invalid_response)
243
254
  rescue JSON::ParserError
244
255
  raise ApiError.new('Invalid JSON response from Slack API', code: :invalid_json)
245
256
  end
@@ -135,6 +135,19 @@ module Slk
135
135
  end
136
136
  end
137
137
 
138
+ # The meta cache holds everything that is neither a user nor a channel:
139
+ # start dates, the deactivation roster, resolved profiles. "Clear all
140
+ # caches" was not telling the truth while this was left behind.
141
+ def clear_meta_cache(workspace_name = nil)
142
+ if workspace_name
143
+ @meta_cache.delete(workspace_name)
144
+ FileUtils.rm_f(meta_cache_file(workspace_name))
145
+ else
146
+ @meta_cache.clear
147
+ Dir.glob(@paths.cache_file('meta-*.json')).each { |f| FileUtils.rm_f(f) }
148
+ end
149
+ end
150
+
138
151
  # Subteam cache methods
139
152
  def get_subteam(workspace_name, subteam_id)
140
153
  load_subteam_cache(workspace_name)
@@ -199,6 +212,12 @@ module Slk
199
212
  @on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
200
213
  safely_delete_file(file)
201
214
  {}
215
+ rescue SystemCallError, IOError => e
216
+ # An unreadable cache file is the same situation as a corrupt one:
217
+ # carry on without it. Not deleted, because a file we cannot read is
218
+ # one we probably cannot remove either, and it may not be ours.
219
+ @on_warning&.call("#{cache_type} cache unreadable for #{workspace_name}: #{e.message}. Continuing without it.")
220
+ {}
202
221
  end
203
222
 
204
223
  def safely_delete_file(file)
@@ -7,6 +7,8 @@ module Slk
7
7
  module MetaCache
8
8
  module_function
9
9
 
10
+ # A write failure here is deliberately dropped: fetch's caller wants the
11
+ # value, and callers that need to report a cold cache use write directly.
10
12
  def fetch(cache_store, workspace_name, key, ttl: nil, refresh: false)
11
13
  cached = read(cache_store, workspace_name, key, ttl: ttl) unless refresh
12
14
  return cached if cached
@@ -22,10 +24,23 @@ module Slk
22
24
  cache_store.get_meta(workspace_name, key, ttl: ttl)
23
25
  end
24
26
 
27
+ # A cache write that fails must never cost the caller the work it just
28
+ # did — a full or read-only disk means "no cache", not "no answer", and
29
+ # some of these writes sit behind minutes of rate-limited API calls.
30
+ #
31
+ # A nil or false value is treated as nothing to store, since no caller
32
+ # caches a negative this way — the start date lookup caches per-user
33
+ # nils inside a Hash, which is a value like any other.
34
+ #
35
+ # @return [Exception, nil] the write failure, for callers that want to
36
+ # mention it; nil when the write succeeded or there was nothing to do
25
37
  def write(cache_store, workspace_name, key, value)
26
- return unless cache_store && workspace_name && value
38
+ return nil unless cache_store && workspace_name && value
27
39
 
28
40
  cache_store.set_meta(workspace_name, key, value)
41
+ nil
42
+ rescue SystemCallError, IOError => e
43
+ e
29
44
  end
30
45
  end
31
46
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Which custom profile field holds a start date, if any.
6
+ #
7
+ # Workspaces name and number these fields themselves, so the ID has to be
8
+ # discovered from the team schema rather than hardcoded — and the schema
9
+ # barely changes, so the answer is cached for a week.
10
+ class StartDateField
11
+ CACHE_KEY = 'start_date_field_v1'
12
+ TTL = 604_800 # 7 days
13
+ LABEL = /\Astart date\z/i
14
+
15
+ def initialize(team_api:, workspace_name:, cache_store: nil, on_debug: nil)
16
+ @team_api = team_api
17
+ @workspace_name = workspace_name
18
+ @cache = cache_store
19
+ @on_debug = on_debug
20
+ end
21
+
22
+ # @return [String, nil] the Xf… field ID, or nil if the workspace has none
23
+ def id
24
+ return @id if defined?(@id)
25
+
26
+ cached = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: TTL)
27
+ @id = cached.is_a?(Hash) ? cached['id'] : discover
28
+ end
29
+
30
+ def missing_message
31
+ 'This workspace has no "Start Date" profile field, so tenure cannot be worked out. ' \
32
+ 'Run `slk debug schema` (an undocumented command) to see the fields it does have.'
33
+ end
34
+
35
+ private
36
+
37
+ # A date-typed field wins over a text one with the same label: someone
38
+ # typing "started summer 2019" into a text box is not a date.
39
+ def discover
40
+ id = usable_id(best_match(@team_api.profile_schema.dig('profile', 'fields')))
41
+ @on_debug&.call("start date field: #{id || 'not found in team schema'}")
42
+ remember(id)
43
+ id
44
+ end
45
+
46
+ def usable_id(match)
47
+ id = match && match['id']
48
+ id.is_a?(String) && !id.empty? ? id : nil
49
+ end
50
+
51
+ # Losing this costs one extra team.profile.get next run, not minutes of
52
+ # rate-limited lookups, so it is noted rather than warned about.
53
+ def remember(id)
54
+ failure = MetaCache.write(@cache, @workspace_name, CACHE_KEY, { 'id' => id })
55
+ @on_debug&.call("start date field cache not written: #{failure.message}") if failure
56
+ end
57
+
58
+ # A workspace that answers with something other than a list of field
59
+ # hashes has no start date field as far as we are concerned — better a
60
+ # clear "this workspace cannot do tenure" than an unexpected error.
61
+ def best_match(fields)
62
+ labelled = Array(fields).grep(Hash).select { |f| LABEL.match?(f['label'].to_s.strip) }
63
+ labelled.find { |f| f['type'] == 'date' } || labelled.first
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Start dates live in a workspace custom profile field, which `users.list`
6
+ # does not return — they cost one `users.profile.get` per person, and Slack
7
+ # rate-limits that endpoint hard enough that a screenful takes minutes
8
+ # rather than seconds.
9
+ #
10
+ # So every answer is cached, and cached the moment it arrives rather than
11
+ # at the end: interrupting a long lookup keeps the work already paid for.
12
+ # Accounts with no start date on file are cached too, otherwise every run
13
+ # would pay again to learn the same nothing.
14
+ #
15
+ # The cache holds for 30 days. A departed account's start date rarely
16
+ # changes, but an admin correcting a typo in one is exactly the case the
17
+ # expiry exists for; `slk cache clear` forces the issue sooner.
18
+ class StartDateLookup
19
+ CACHE_KEY = 'start_dates_v1'
20
+ TTL = 2_592_000 # 30 days
21
+
22
+ class MissingFieldError < Slk::Error; end
23
+
24
+ # Set when the cache could not be written. The lookup carries on — the
25
+ # cache is an optimisation, and the answers are worth more than it.
26
+ attr_reader :cache_error
27
+
28
+ def initialize(users_api:, field:, workspace_name:, cache_store: nil, on_progress: nil)
29
+ @users_api = users_api
30
+ @field = field
31
+ @workspace_name = workspace_name
32
+ @cache = cache_store
33
+ @on_progress = on_progress
34
+ end
35
+
36
+ # @return [Hash{String => String, nil}] user ID => ISO date, or nil for
37
+ # an account with no start date recorded
38
+ def fetch(user_ids)
39
+ # Checked even for an empty list: "this workspace cannot do tenure" is
40
+ # the same fact whether or not anyone matched the filter, and finding
41
+ # out only when someone matches makes it look intermittent.
42
+ raise MissingFieldError, @field.missing_message if @field.id.to_s.empty?
43
+ return {} if user_ids.empty?
44
+
45
+ known = cached
46
+ user_ids.each_with_index { |id, index| resolve(known, id, index, user_ids.size) }
47
+ known.slice(*user_ids)
48
+ end
49
+
50
+ # How many of these would cost an API call, so a caller can warn about
51
+ # the wait before making someone sit through it.
52
+ def uncached_count(user_ids)
53
+ known = cached
54
+ user_ids.count { |id| !known.key?(id) }
55
+ end
56
+
57
+ private
58
+
59
+ def resolve(known, user_id, index, total)
60
+ return if known.key?(user_id)
61
+
62
+ @on_progress&.call(index + 1, total)
63
+ known[user_id] = start_date_for(user_id)
64
+ remember(known)
65
+ end
66
+
67
+ # A full or read-only disk must not throw away a lookup that cost
68
+ # minutes of rate-limited calls. MetaCache.write hands back the failure
69
+ # instead of raising; the caller reports it once at the end.
70
+ def remember(known)
71
+ failure = MetaCache.write(@cache, @workspace_name, CACHE_KEY, known)
72
+ # First failure only: the same unwritable disk will fail every row.
73
+ @cache_error = failure.message if failure && @cache_error.nil?
74
+ end
75
+
76
+ # Which account failed matters when the answer arrives twenty rows into
77
+ # a run that has already taken three minutes.
78
+ def start_date_for(user_id)
79
+ fields = @users_api.profile_for(user_id).dig('profile', 'fields')
80
+ value = fields.is_a?(Hash) ? fields.dig(@field.id, 'value') : nil
81
+ value.to_s.empty? ? nil : value
82
+ rescue ApiError => e
83
+ # Only the plain kind: re-wrapping a RateLimitError would drop its
84
+ # retry_after and the class the retry logic looks for.
85
+ raise unless e.instance_of?(ApiError)
86
+
87
+ raise ApiError.new("#{e.message} (looking up #{user_id})", code: e.code)
88
+ end
89
+
90
+ def cached
91
+ @cached ||= begin
92
+ data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: TTL)
93
+ data.is_a?(Hash) ? data : {}
94
+ end
95
+ end
96
+ end
97
+ end
98
+ 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.9.0'
4
+ VERSION = '0.10.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'fileutils'
7
7
  require 'optparse'
8
8
  require 'time'
9
+ require 'date'
9
10
  require 'io/console'
10
11
 
11
12
  # Slack CLI - A command-line interface for Slack
@@ -75,6 +76,7 @@ module Slk
75
76
  autoload :Channel, 'slk/models/channel'
76
77
  autoload :Preset, 'slk/models/preset'
77
78
  autoload :Deactivation, 'slk/models/deactivation'
79
+ autoload :Tenure, 'slk/models/tenure'
78
80
  autoload :SearchResult, 'slk/models/search_result'
79
81
  autoload :SavedItem, 'slk/models/saved_item'
80
82
  autoload :Profile, 'slk/models/profile'
@@ -109,6 +111,8 @@ module Slk
109
111
  autoload :ProfileResolver, 'slk/services/profile_resolver'
110
112
  autoload :MetaCache, 'slk/services/meta_cache'
111
113
  autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
114
+ autoload :StartDateLookup, 'slk/services/start_date_lookup'
115
+ autoload :StartDateField, 'slk/services/start_date_field'
112
116
  end
113
117
 
114
118
  # Output formatters for messages, durations, and emoji
@@ -132,6 +136,8 @@ module Slk
132
136
  autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
133
137
  autoload :ProfileRows, 'slk/formatters/profile_rows'
134
138
  autoload :DeactivationFormatter, 'slk/formatters/deactivation_formatter'
139
+ autoload :DeactivationCsv, 'slk/formatters/deactivation_csv'
140
+ autoload :CsvWriter, 'slk/formatters/csv_writer'
135
141
  end
136
142
 
137
143
  # CLI commands implementing user-facing functionality
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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -65,6 +65,8 @@ files:
65
65
  - lib/slk/formatters/activity_formatter.rb
66
66
  - lib/slk/formatters/attachment_formatter.rb
67
67
  - lib/slk/formatters/block_formatter.rb
68
+ - lib/slk/formatters/csv_writer.rb
69
+ - lib/slk/formatters/deactivation_csv.rb
68
70
  - lib/slk/formatters/deactivation_formatter.rb
69
71
  - lib/slk/formatters/duration_formatter.rb
70
72
  - lib/slk/formatters/emoji_replacer.rb
@@ -95,6 +97,7 @@ files:
95
97
  - lib/slk/models/search_result.rb
96
98
  - lib/slk/models/status.rb
97
99
  - lib/slk/models/status_snapshot.rb
100
+ - lib/slk/models/tenure.rb
98
101
  - lib/slk/models/user.rb
99
102
  - lib/slk/models/workspace.rb
100
103
  - lib/slk/runner.rb
@@ -115,6 +118,8 @@ files:
115
118
  - lib/slk/services/profile_resolver.rb
116
119
  - lib/slk/services/reaction_enricher.rb
117
120
  - lib/slk/services/setup_wizard.rb
121
+ - lib/slk/services/start_date_field.rb
122
+ - lib/slk/services/start_date_lookup.rb
118
123
  - lib/slk/services/target_resolver.rb
119
124
  - lib/slk/services/token_loader.rb
120
125
  - lib/slk/services/token_saver.rb