slk 0.9.0 → 0.11.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 +47 -0
- data/README.md +37 -0
- data/lib/slk/api/conversations.rb +6 -2
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +144 -14
- data/lib/slk/commands/help.rb +2 -0
- data/lib/slk/commands/search.rb +55 -48
- data/lib/slk/commands/sent.rb +288 -0
- data/lib/slk/formatters/attachment_formatter.rb +7 -3
- 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/formatters/search_formatter.rb +25 -11
- data/lib/slk/formatters/sent_formatter.rb +193 -0
- data/lib/slk/models/search_result.rb +2 -0
- data/lib/slk/models/tenure.rb +66 -0
- data/lib/slk/runner.rb +4 -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/search_pages.rb +65 -0
- data/lib/slk/services/sent_changes.rb +293 -0
- data/lib/slk/services/sent_channel_label.rb +66 -0
- data/lib/slk/services/sent_conversations.rb +227 -0
- data/lib/slk/services/start_date_field.rb +67 -0
- data/lib/slk/services/start_date_lookup.rb +98 -0
- data/lib/slk/support/check_in_time.rb +73 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +13 -0
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7db83e915de4b86bafd0cc62e8a67fdaee0dcd9209cb94a1f8fd6a5a2704c46
|
|
4
|
+
data.tar.gz: eb25d2aafaa23a51016e64c2f5eb40c0117aeacb829d784caff9a50078946992
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d928dbf2906c22dc3ad108503c6ef4bfaeb68c29c0ed562dcd27e3515cf1468df426801dc6f7e73a3c2f9bb47d79fd7bf90054b3a9fc424453a35e8f60fd75d
|
|
7
|
+
data.tar.gz: 93850e6e114cd1634ce2546631b1c370eb94731ff284eb29c05c458a533f16fb1229afde2ca27f60e6ea2e00d12828d6db17342f1d336350cc59d5ae788cc1bb
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ 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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.11.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `slk sent` now expands your sent-message search hits into conversations: full paginated threads and DMs, merged channel windows (5 messages before and 30 minutes after your posts), and replies to your top-level posts. It groups by conversation and caps displayed messages with `--max` (default 200, `0` for all). `--before` and `--after-minutes` tune channel windows. `--mine` keeps the previous flat timeline and JSON shape.
|
|
15
|
+
- `slk sent --json` now returns `{date, range, conversations}`. Each conversation includes its workspace, channel, type, thread timestamp, `last_speaker_is_me`, `dropped_messages`, and chronological messages with sender, name, text, and `mine`. `channel_name` remains Slack's raw value; `channel_label` is the resolved display label for DMs and channels. The sender-only format remains available through `--mine --json`, with `channel_label` added to counts.
|
|
16
|
+
|
|
17
|
+
- `slk sent` text now wraps to the interactive terminal's width (or `--width N`), with indented reply and context continuations; `--no-wrap` disables wrapping. The redundant `▶` marker on your own messages and the last-speaker status lines are gone from text output; JSON still includes `last_speaker_is_me`. Thread headings now show the thread ID instead of repeating the parent message; channel and DM headings keep their readable names. A thin divider separates conversations without splitting context from new messages. Orphaned replies put their parent reference on a separate line so long thread IDs do not steal the message's wrap width.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `slk sent --changed-since TIME` shows a stateless diff of conversations you participated in over `--lookback DAYS` (default 7). TIME accepts local `H:MM` or `HH:MM`, ISO datetime, Slack epoch/ts, or `90m`/`2h`/`1d`; clock times use the latest local occurrence (yesterday when that time has not arrived today). Search discovers conversations, but exact-timestamp history and replies determine what changed: old threads and DMs with new replies appear even when you did not post today. Channel chatter alone does not count; channel windows are fetched only for your own new top-level posts. Changed output orders conversations by latest activity, newest at the bottom, and includes up to `--context N` earlier messages (default 2). Text marks older context with `·` without a separate cutoff divider; JSON labels each new message. JSON adds `changed_since: {iso, ts}`, `lookback_days`, per-conversation `new_count` and `new_from_others`, and per-message `new`, retaining the flat, timestamp-sorted `thread_ts` contract. Thread subscriptions contribute unread followed threads when available. A paginated history scan per watched channel identifies active thread roots from Slack's `latest_reply` (rather than probing every root); quiet DMs use a one-message history probe. Edits/reactions do not change message timestamps; conversations you never posted in and very recent posts missing from Slack's search index remain blind spots.
|
|
22
|
+
- `slk sent [today|yesterday|YYYY-MM-DD]` finds conversations you posted in, across workspaces by default. `--since YYYY-MM-DD` searches through today, `-w` selects a workspace, and `--mine` shows only your indexed messages in a flat chronological timeline with per-channel counts. Slack's index can lag, excludes deleted messages, and uses your profile timezone for date operators; DM history bounds use your local timezone.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `slk sent` now orders conversations with exact Slack timestamps and workspace/channel/thread tie-breakers, so identical start times stay deterministic across platforms.
|
|
27
|
+
- `slk sent --changed-since` no longer silently omits followed threads when their lookup fails due to network, rate-limit, authentication, or malformed-response errors. Unsupported or unauthorized-to-use subscription views still fall back to search-derived conversations. Sender lookup caches the authenticated user ID and reports a workspace-specific API error if Slack omits it. Deleted threads are recognized by Slack's error code, not message text, and invalid-time errors are limited to actual input conversions. Text wrapping falls back to 72 columns if a terminal cannot report its width.
|
|
28
|
+
- `slk sent` now includes the first post of each channel window in Slack history (and expands its replies), displays DM names even for threads, resolves group-DM participants, and shows file-only thread parents through file labels. Expanded replies now appear nested beneath their parent in text output, while JSON remains flat and timestamp-sorted with `thread_ts` links.
|
|
29
|
+
- Attachment and unfurl preview titles, authors, text, and image labels now decode HTML entities like message bodies.
|
|
30
|
+
- `slk search --all` now searches every workspace and tags each result. Search paginates past Slack's 100-result page size when `-n` requests more, and warns when the requested limit cuts off matches.
|
|
31
|
+
|
|
32
|
+
## [0.10.0] - 2026-09-18
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`slk deactivations --tenure`** — how long each person stayed
|
|
37
|
+
- 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
|
|
38
|
+
- `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
|
|
39
|
+
- 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
|
|
40
|
+
- 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
|
|
41
|
+
- 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
|
|
42
|
+
- **`slk deactivations --csv`** — the spreadsheet that always gets asked for
|
|
43
|
+
- Writes every match rather than the screenful `-n` would show: a truncated export is a wrong answer that looks like a right one
|
|
44
|
+
- 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
|
|
45
|
+
- RFC 4180 quoting, hand-rolled, because Ruby 3.4 moved csv out of the default gems and this tool ships with no dependencies
|
|
46
|
+
- Progress and warnings go to stderr, so `slk deactivations --csv > file.csv` captures only data
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **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
|
|
51
|
+
- **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
|
|
52
|
+
- **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
|
|
53
|
+
- **`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
|
|
54
|
+
|
|
8
55
|
## [0.9.0] - 2026-09-18
|
|
9
56
|
|
|
10
57
|
### 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
|
|
@@ -15,17 +15,21 @@ module Slk
|
|
|
15
15
|
@api.post(@workspace, 'conversations.list', params)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# rubocop:disable Metrics/ParameterLists
|
|
19
|
+
def history(channel:, limit: 20, cursor: nil, oldest: nil, latest: nil, inclusive: false)
|
|
19
20
|
params = { channel: channel, limit: limit }
|
|
20
21
|
params[:cursor] = cursor if cursor
|
|
21
22
|
params[:oldest] = oldest if oldest
|
|
22
23
|
params[:latest] = latest if latest
|
|
24
|
+
params[:inclusive] = true if inclusive
|
|
23
25
|
@api.post(@workspace, 'conversations.history', params)
|
|
24
26
|
end
|
|
27
|
+
# rubocop:enable Metrics/ParameterLists
|
|
25
28
|
|
|
26
|
-
def replies(channel:, timestamp:, limit: 100, cursor: nil)
|
|
29
|
+
def replies(channel:, timestamp:, limit: 100, cursor: nil, oldest: nil)
|
|
27
30
|
params = { channel: channel, ts: timestamp, limit: limit }
|
|
28
31
|
params[:cursor] = cursor if cursor
|
|
32
|
+
params[:oldest] = oldest if oldest
|
|
29
33
|
# Use form encoding - some workspaces (Enterprise Grid) require it
|
|
30
34
|
@api.post_form(@workspace, 'conversations.replies', params)
|
|
31
35
|
end
|
data/lib/slk/cli.rb
CHANGED
data/lib/slk/commands/cache.rb
CHANGED
|
@@ -65,6 +65,14 @@ module Slk
|
|
|
65
65
|
0
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# Every kind, including the meta cache behind start dates, deactivation
|
|
69
|
+
# rosters and resolved profiles — "cleared" should not have exceptions.
|
|
70
|
+
def clear_every_cache(workspace_name)
|
|
71
|
+
cache_store.clear_user_cache(*[workspace_name].compact)
|
|
72
|
+
cache_store.clear_channel_cache(*[workspace_name].compact)
|
|
73
|
+
cache_store.clear_meta_cache(*[workspace_name].compact)
|
|
74
|
+
end
|
|
75
|
+
|
|
68
76
|
def display_workspace_status(workspace)
|
|
69
77
|
puts output.bold(workspace.name) if target_workspaces.size > 1
|
|
70
78
|
display_cache_counts(workspace)
|
|
@@ -87,15 +95,8 @@ module Slk
|
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def clear_cache(workspace_name)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
cache_store.clear_channel_cache(workspace_name)
|
|
93
|
-
success("Cleared cache for #{workspace_name}")
|
|
94
|
-
else
|
|
95
|
-
cache_store.clear_user_cache
|
|
96
|
-
cache_store.clear_channel_cache
|
|
97
|
-
success('Cleared all caches')
|
|
98
|
-
end
|
|
98
|
+
clear_every_cache(workspace_name)
|
|
99
|
+
success(workspace_name ? "Cleared cache for #{workspace_name}" : 'Cleared all caches')
|
|
99
100
|
|
|
100
101
|
0
|
|
101
102
|
end
|
|
@@ -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
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Slk
|
|
|
12
12
|
['dnd', 'Manage Do Not Disturb'],
|
|
13
13
|
['messages', 'Read channel or DM messages'],
|
|
14
14
|
['search', 'Search messages across channels'],
|
|
15
|
+
['sent', 'Show your sent messages across workspaces'],
|
|
15
16
|
['unread', 'View and clear unread messages'],
|
|
16
17
|
['activity', 'Show activity feed (reactions, mentions, threads)'],
|
|
17
18
|
['later', 'Show saved "Later" items'],
|
|
@@ -84,6 +85,7 @@ module Slk
|
|
|
84
85
|
slk status schedule "Vet" 1p-3p Schedule a status (am/pm or 24h)
|
|
85
86
|
slk dnd 1h Enable DND for 1 hour
|
|
86
87
|
slk messages #general Read channel messages
|
|
88
|
+
slk sent Show messages you sent today
|
|
87
89
|
slk preset meeting Apply preset
|
|
88
90
|
EXAMPLES
|
|
89
91
|
end
|
data/lib/slk/commands/search.rb
CHANGED
|
@@ -41,8 +41,8 @@ module Slk
|
|
|
41
41
|
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
42
42
|
def handle_option(arg, args, remaining)
|
|
43
43
|
case arg
|
|
44
|
-
when '-n', '--limit' then @options[:limit] =
|
|
45
|
-
when '--page' then @options[:page] =
|
|
44
|
+
when '-n', '--limit' then @options[:limit] = positive_integer(arg, args)
|
|
45
|
+
when '--page' then @options[:page] = positive_integer(arg, args)
|
|
46
46
|
when '--in' then @options[:in_channel] = require_value(arg, args)
|
|
47
47
|
when '--from' then @options[:from_user] = require_value(arg, args)
|
|
48
48
|
when '--after' then @options[:after_date] = require_value(arg, args)
|
|
@@ -56,11 +56,19 @@ module Slk
|
|
|
56
56
|
|
|
57
57
|
def require_value(option, args)
|
|
58
58
|
value = args.shift
|
|
59
|
-
raise ArgumentError, "#{option} requires a value" unless value
|
|
59
|
+
raise ArgumentError, "#{option} requires a value" unless value && !value.start_with?('-')
|
|
60
60
|
|
|
61
61
|
value
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def positive_integer(option, args)
|
|
65
|
+
value = require_value(option, args)
|
|
66
|
+
number = Integer(value, exception: false)
|
|
67
|
+
raise ArgumentError, "#{option} expects a positive integer" unless number&.positive?
|
|
68
|
+
|
|
69
|
+
number
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
def help_text
|
|
65
73
|
help = Support::HelpFormatter.new('slk search <query> [options]')
|
|
66
74
|
help.description('Search messages across channels and DMs.')
|
|
@@ -84,11 +92,12 @@ module Slk
|
|
|
84
92
|
|
|
85
93
|
def add_options_section(help)
|
|
86
94
|
help.section('OPTIONS') do |s|
|
|
87
|
-
s.option('-n, --limit N', '
|
|
88
|
-
s.option('--page N', '
|
|
95
|
+
s.option('-n, --limit N', 'Results per workspace (default: 20; paginates past 100)')
|
|
96
|
+
s.option('--page N', 'Starting page in each workspace')
|
|
89
97
|
s.option('--threads', 'Show thread replies inline')
|
|
90
98
|
s.option('--json', 'Output as JSON')
|
|
91
|
-
s.option('-w, --workspace', 'Specify workspace')
|
|
99
|
+
s.option('-w, --workspace NAME', 'Specify workspace')
|
|
100
|
+
s.option('--all', 'Search every workspace (results tagged by workspace)')
|
|
92
101
|
s.option('-v, --verbose', 'Show debug information')
|
|
93
102
|
s.option('-q, --quiet', 'Suppress output')
|
|
94
103
|
end
|
|
@@ -110,23 +119,19 @@ module Slk
|
|
|
110
119
|
1
|
|
111
120
|
end
|
|
112
121
|
|
|
113
|
-
# rubocop:disable Metrics/MethodLength
|
|
114
122
|
def search_and_display(query)
|
|
115
|
-
workspace
|
|
116
|
-
full_query = build_query(query)
|
|
123
|
+
raise ArgumentError, 'Use either --all or --workspace, not both' if @options[:all] && @options[:workspace]
|
|
117
124
|
|
|
125
|
+
full_query = build_query(query)
|
|
118
126
|
debug("Searching: #{full_query}")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
results = parse_results(response)
|
|
126
|
-
display_results(results, workspace, response)
|
|
127
|
+
searches = target_workspaces.map do |workspace|
|
|
128
|
+
[workspace, Services::SearchPages.new(runner.search_api(workspace.name)).fetch(
|
|
129
|
+
query: full_query, limit: @options[:limit], page: @options[:page]
|
|
130
|
+
)]
|
|
131
|
+
end
|
|
132
|
+
display_results(searches)
|
|
127
133
|
0
|
|
128
134
|
end
|
|
129
|
-
# rubocop:enable Metrics/MethodLength
|
|
130
135
|
|
|
131
136
|
def build_query(base_query)
|
|
132
137
|
parts = [base_query]
|
|
@@ -138,47 +143,50 @@ module Slk
|
|
|
138
143
|
parts.join(' ')
|
|
139
144
|
end
|
|
140
145
|
|
|
141
|
-
def
|
|
142
|
-
matches = response.dig('messages', 'matches') || []
|
|
143
|
-
matches.map { |m| Models::SearchResult.from_api(m) }
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def display_results(results, workspace, response)
|
|
146
|
+
def display_results(searches)
|
|
147
147
|
if @options[:json]
|
|
148
|
-
output_json_results(
|
|
148
|
+
output_json_results(searches)
|
|
149
149
|
else
|
|
150
|
-
display_text_results(
|
|
150
|
+
display_text_results(searches)
|
|
151
151
|
end
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
def output_json_results(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
page: pagination['page'],
|
|
160
|
-
page_count: pagination['page_count'],
|
|
161
|
-
total_count: pagination['total_count']
|
|
162
|
-
}
|
|
163
|
-
})
|
|
154
|
+
def output_json_results(searches)
|
|
155
|
+
results = searches.flat_map do |workspace, data|
|
|
156
|
+
data[:results].map { |result| result.to_h.merge(workspace: workspace.name) }
|
|
157
|
+
end
|
|
158
|
+
output_json(results: results, pagination: json_pagination(searches))
|
|
164
159
|
end
|
|
165
160
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if results.empty?
|
|
170
|
-
puts 'No results found.'
|
|
171
|
-
return
|
|
161
|
+
def json_pagination(searches)
|
|
162
|
+
pages = searches.to_h do |workspace, data|
|
|
163
|
+
[workspace.name, data[:pagination].merge('truncated' => data[:truncated] == true)]
|
|
172
164
|
end
|
|
165
|
+
searches.length == 1 ? pages.values.first : pages
|
|
166
|
+
end
|
|
173
167
|
|
|
174
|
-
|
|
168
|
+
def display_text_results(searches)
|
|
169
|
+
searches.each { |workspace, data| report_search(workspace, data) }
|
|
170
|
+
entries = searches.flat_map { |workspace, data| data[:results].map { |result| [result, workspace] } }
|
|
171
|
+
return puts 'No results found.' if entries.empty?
|
|
172
|
+
|
|
173
|
+
entries.each_with_index do |(result, workspace), index|
|
|
175
174
|
display_single_result(result, workspace)
|
|
176
|
-
puts if index <
|
|
175
|
+
puts if index < entries.length - 1
|
|
177
176
|
end
|
|
178
177
|
end
|
|
179
178
|
|
|
179
|
+
def report_search(workspace, data)
|
|
180
|
+
show_pagination_info(data[:pagination], workspace) if @options[:verbose]
|
|
181
|
+
return unless data[:truncated]
|
|
182
|
+
|
|
183
|
+
total = data[:pagination]['total_count']
|
|
184
|
+
quantity = total ? "#{data[:results].length} of #{total}" : data[:results].length.to_s
|
|
185
|
+
warn("#{workspace.name}: Showing #{quantity} matches; raise -n for more.")
|
|
186
|
+
end
|
|
187
|
+
|
|
180
188
|
def display_single_result(result, workspace)
|
|
181
|
-
runner.search_formatter.display_result(result, workspace, format_options)
|
|
189
|
+
runner.search_formatter.display_result(result, workspace, format_options.merge(workspace_label: @options[:all]))
|
|
182
190
|
show_thread_replies(result, workspace) if should_show_thread?(result)
|
|
183
191
|
end
|
|
184
192
|
|
|
@@ -209,13 +217,12 @@ module Slk
|
|
|
209
217
|
lines[1..].each { |line| puts " #{line}" }
|
|
210
218
|
end
|
|
211
219
|
|
|
212
|
-
def show_pagination_info(
|
|
213
|
-
pagination = response.dig('messages', 'pagination') || {}
|
|
220
|
+
def show_pagination_info(pagination, workspace)
|
|
214
221
|
total = pagination['total_count'] || 0
|
|
215
222
|
page = pagination['page'] || 1
|
|
216
223
|
page_count = pagination['page_count'] || 1
|
|
217
224
|
|
|
218
|
-
debug("Page #{page}/#{page_count} (#{total} total results)")
|
|
225
|
+
debug("#{workspace.name}: Page #{page}/#{page_count} (#{total} total results)")
|
|
219
226
|
end
|
|
220
227
|
end
|
|
221
228
|
# rubocop:enable Metrics/ClassLength
|