slk 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +67 -0
- data/lib/slk/api/users.rb +27 -0
- data/lib/slk/cli.rb +2 -1
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +415 -0
- data/lib/slk/commands/help.rb +25 -18
- 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 +124 -0
- data/lib/slk/formatters/output.rb +39 -0
- data/lib/slk/models/deactivation.rb +77 -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/deactivation_scanner.rb +105 -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 +10 -0
- metadata +10 -1
|
@@ -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
|
|
@@ -74,6 +75,8 @@ module Slk
|
|
|
74
75
|
autoload :User, 'slk/models/user'
|
|
75
76
|
autoload :Channel, 'slk/models/channel'
|
|
76
77
|
autoload :Preset, 'slk/models/preset'
|
|
78
|
+
autoload :Deactivation, 'slk/models/deactivation'
|
|
79
|
+
autoload :Tenure, 'slk/models/tenure'
|
|
77
80
|
autoload :SearchResult, 'slk/models/search_result'
|
|
78
81
|
autoload :SavedItem, 'slk/models/saved_item'
|
|
79
82
|
autoload :Profile, 'slk/models/profile'
|
|
@@ -107,6 +110,9 @@ module Slk
|
|
|
107
110
|
autoload :ProfileBuilder, 'slk/services/profile_builder'
|
|
108
111
|
autoload :ProfileResolver, 'slk/services/profile_resolver'
|
|
109
112
|
autoload :MetaCache, 'slk/services/meta_cache'
|
|
113
|
+
autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
|
|
114
|
+
autoload :StartDateLookup, 'slk/services/start_date_lookup'
|
|
115
|
+
autoload :StartDateField, 'slk/services/start_date_field'
|
|
110
116
|
end
|
|
111
117
|
|
|
112
118
|
# Output formatters for messages, durations, and emoji
|
|
@@ -129,6 +135,9 @@ module Slk
|
|
|
129
135
|
autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
|
|
130
136
|
autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
|
|
131
137
|
autoload :ProfileRows, 'slk/formatters/profile_rows'
|
|
138
|
+
autoload :DeactivationFormatter, 'slk/formatters/deactivation_formatter'
|
|
139
|
+
autoload :DeactivationCsv, 'slk/formatters/deactivation_csv'
|
|
140
|
+
autoload :CsvWriter, 'slk/formatters/csv_writer'
|
|
132
141
|
end
|
|
133
142
|
|
|
134
143
|
# CLI commands implementing user-facing functionality
|
|
@@ -153,6 +162,7 @@ module Slk
|
|
|
153
162
|
autoload :Debug, 'slk/commands/debug'
|
|
154
163
|
autoload :Who, 'slk/commands/who'
|
|
155
164
|
autoload :Org, 'slk/commands/org'
|
|
165
|
+
autoload :Deactivations, 'slk/commands/deactivations'
|
|
156
166
|
end
|
|
157
167
|
|
|
158
168
|
# Thin wrappers around Slack API endpoints
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: slk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -45,6 +45,7 @@ files:
|
|
|
45
45
|
- lib/slk/commands/cache.rb
|
|
46
46
|
- lib/slk/commands/catchup.rb
|
|
47
47
|
- lib/slk/commands/config.rb
|
|
48
|
+
- lib/slk/commands/deactivations.rb
|
|
48
49
|
- lib/slk/commands/debug.rb
|
|
49
50
|
- lib/slk/commands/dnd.rb
|
|
50
51
|
- lib/slk/commands/emoji.rb
|
|
@@ -64,6 +65,9 @@ files:
|
|
|
64
65
|
- lib/slk/formatters/activity_formatter.rb
|
|
65
66
|
- lib/slk/formatters/attachment_formatter.rb
|
|
66
67
|
- lib/slk/formatters/block_formatter.rb
|
|
68
|
+
- lib/slk/formatters/csv_writer.rb
|
|
69
|
+
- lib/slk/formatters/deactivation_csv.rb
|
|
70
|
+
- lib/slk/formatters/deactivation_formatter.rb
|
|
67
71
|
- lib/slk/formatters/duration_formatter.rb
|
|
68
72
|
- lib/slk/formatters/emoji_replacer.rb
|
|
69
73
|
- lib/slk/formatters/json_message_formatter.rb
|
|
@@ -80,6 +84,7 @@ files:
|
|
|
80
84
|
- lib/slk/formatters/search_formatter.rb
|
|
81
85
|
- lib/slk/formatters/text_processor.rb
|
|
82
86
|
- lib/slk/models/channel.rb
|
|
87
|
+
- lib/slk/models/deactivation.rb
|
|
83
88
|
- lib/slk/models/dnd_state.rb
|
|
84
89
|
- lib/slk/models/duration.rb
|
|
85
90
|
- lib/slk/models/message.rb
|
|
@@ -92,6 +97,7 @@ files:
|
|
|
92
97
|
- lib/slk/models/search_result.rb
|
|
93
98
|
- lib/slk/models/status.rb
|
|
94
99
|
- lib/slk/models/status_snapshot.rb
|
|
100
|
+
- lib/slk/models/tenure.rb
|
|
95
101
|
- lib/slk/models/user.rb
|
|
96
102
|
- lib/slk/models/workspace.rb
|
|
97
103
|
- lib/slk/runner.rb
|
|
@@ -99,6 +105,7 @@ files:
|
|
|
99
105
|
- lib/slk/services/api_client.rb
|
|
100
106
|
- lib/slk/services/cache_store.rb
|
|
101
107
|
- lib/slk/services/configuration.rb
|
|
108
|
+
- lib/slk/services/deactivation_scanner.rb
|
|
102
109
|
- lib/slk/services/emoji_downloader.rb
|
|
103
110
|
- lib/slk/services/emoji_searcher.rb
|
|
104
111
|
- lib/slk/services/encryption.rb
|
|
@@ -111,6 +118,8 @@ files:
|
|
|
111
118
|
- lib/slk/services/profile_resolver.rb
|
|
112
119
|
- lib/slk/services/reaction_enricher.rb
|
|
113
120
|
- lib/slk/services/setup_wizard.rb
|
|
121
|
+
- lib/slk/services/start_date_field.rb
|
|
122
|
+
- lib/slk/services/start_date_lookup.rb
|
|
114
123
|
- lib/slk/services/target_resolver.rb
|
|
115
124
|
- lib/slk/services/token_loader.rb
|
|
116
125
|
- lib/slk/services/token_saver.rb
|