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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +37 -0
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +144 -14
- data/lib/slk/formatters/csv_writer.rb +34 -0
- data/lib/slk/formatters/deactivation_csv.rb +49 -0
- data/lib/slk/formatters/deactivation_formatter.rb +27 -11
- data/lib/slk/formatters/output.rb +39 -0
- data/lib/slk/models/tenure.rb +66 -0
- data/lib/slk/services/api_client.rb +12 -1
- data/lib/slk/services/cache_store.rb +19 -0
- data/lib/slk/services/meta_cache.rb +16 -1
- data/lib/slk/services/start_date_field.rb +67 -0
- data/lib/slk/services/start_date_lookup.rb +98 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +6 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eec8154512ee9815597065ac68471bfd2ad55535191d4057e55ae4d942f15f66
|
|
4
|
+
data.tar.gz: 3fe2bdbe0fa65ce0b0ee4a529531a38c59f78c674eed08f22d5d40b833b79150
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9a595db19f0748ccb42a4ec436dffa0fe4bbb4d1fbff0514f659cee09a8fb1db2c5258cdd7e74252972c2029e63de0082a2cadf33d96f98dff1681717680e88
|
|
7
|
+
data.tar.gz: 0e33aa2ec6319fe88433dffe30865d0c17306da34cf136aebd1e9b17e9bb42d9bc5447748914751a74e48f43883bfe3cc404fb657cfaf33c2abdf7db369d2e9a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,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
|
data/lib/slk/commands/cache.rb
CHANGED
|
@@ -65,6 +65,14 @@ module Slk
|
|
|
65
65
|
0
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# Every kind, including the meta cache behind start dates, deactivation
|
|
69
|
+
# rosters and resolved profiles — "cleared" should not have exceptions.
|
|
70
|
+
def clear_every_cache(workspace_name)
|
|
71
|
+
cache_store.clear_user_cache(*[workspace_name].compact)
|
|
72
|
+
cache_store.clear_channel_cache(*[workspace_name].compact)
|
|
73
|
+
cache_store.clear_meta_cache(*[workspace_name].compact)
|
|
74
|
+
end
|
|
75
|
+
|
|
68
76
|
def display_workspace_status(workspace)
|
|
69
77
|
puts output.bold(workspace.name) if target_workspaces.size > 1
|
|
70
78
|
display_cache_counts(workspace)
|
|
@@ -87,15 +95,8 @@ module Slk
|
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def clear_cache(workspace_name)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
cache_store.clear_channel_cache(workspace_name)
|
|
93
|
-
success("Cleared cache for #{workspace_name}")
|
|
94
|
-
else
|
|
95
|
-
cache_store.clear_user_cache
|
|
96
|
-
cache_store.clear_channel_cache
|
|
97
|
-
success('Cleared all caches')
|
|
98
|
-
end
|
|
98
|
+
clear_every_cache(workspace_name)
|
|
99
|
+
success(workspace_name ? "Cleared cache for #{workspace_name}" : 'Cleared all caches')
|
|
99
100
|
|
|
100
101
|
0
|
|
101
102
|
end
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
19
|
-
# (rather than month headings) so the output stays greppable.
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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 =
|
|
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
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.
|
|
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
|