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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Which custom profile field holds a start date, if any.
6
+ #
7
+ # Workspaces name and number these fields themselves, so the ID has to be
8
+ # discovered from the team schema rather than hardcoded — and the schema
9
+ # barely changes, so the answer is cached for a week.
10
+ class StartDateField
11
+ CACHE_KEY = 'start_date_field_v1'
12
+ TTL = 604_800 # 7 days
13
+ LABEL = /\Astart date\z/i
14
+
15
+ def initialize(team_api:, workspace_name:, cache_store: nil, on_debug: nil)
16
+ @team_api = team_api
17
+ @workspace_name = workspace_name
18
+ @cache = cache_store
19
+ @on_debug = on_debug
20
+ end
21
+
22
+ # @return [String, nil] the Xf… field ID, or nil if the workspace has none
23
+ def id
24
+ return @id if defined?(@id)
25
+
26
+ cached = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: TTL)
27
+ @id = cached.is_a?(Hash) ? cached['id'] : discover
28
+ end
29
+
30
+ def missing_message
31
+ 'This workspace has no "Start Date" profile field, so tenure cannot be worked out. ' \
32
+ 'Run `slk debug schema` (an undocumented command) to see the fields it does have.'
33
+ end
34
+
35
+ private
36
+
37
+ # A date-typed field wins over a text one with the same label: someone
38
+ # typing "started summer 2019" into a text box is not a date.
39
+ def discover
40
+ id = usable_id(best_match(@team_api.profile_schema.dig('profile', 'fields')))
41
+ @on_debug&.call("start date field: #{id || 'not found in team schema'}")
42
+ remember(id)
43
+ id
44
+ end
45
+
46
+ def usable_id(match)
47
+ id = match && match['id']
48
+ id.is_a?(String) && !id.empty? ? id : nil
49
+ end
50
+
51
+ # Losing this costs one extra team.profile.get next run, not minutes of
52
+ # rate-limited lookups, so it is noted rather than warned about.
53
+ def remember(id)
54
+ failure = MetaCache.write(@cache, @workspace_name, CACHE_KEY, { 'id' => id })
55
+ @on_debug&.call("start date field cache not written: #{failure.message}") if failure
56
+ end
57
+
58
+ # A workspace that answers with something other than a list of field
59
+ # hashes has no start date field as far as we are concerned — better a
60
+ # clear "this workspace cannot do tenure" than an unexpected error.
61
+ def best_match(fields)
62
+ labelled = Array(fields).grep(Hash).select { |f| LABEL.match?(f['label'].to_s.strip) }
63
+ labelled.find { |f| f['type'] == 'date' } || labelled.first
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Start dates live in a workspace custom profile field, which `users.list`
6
+ # does not return — they cost one `users.profile.get` per person, and Slack
7
+ # rate-limits that endpoint hard enough that a screenful takes minutes
8
+ # rather than seconds.
9
+ #
10
+ # So every answer is cached, and cached the moment it arrives rather than
11
+ # at the end: interrupting a long lookup keeps the work already paid for.
12
+ # Accounts with no start date on file are cached too, otherwise every run
13
+ # would pay again to learn the same nothing.
14
+ #
15
+ # The cache holds for 30 days. A departed account's start date rarely
16
+ # changes, but an admin correcting a typo in one is exactly the case the
17
+ # expiry exists for; `slk cache clear` forces the issue sooner.
18
+ class StartDateLookup
19
+ CACHE_KEY = 'start_dates_v1'
20
+ TTL = 2_592_000 # 30 days
21
+
22
+ class MissingFieldError < Slk::Error; end
23
+
24
+ # Set when the cache could not be written. The lookup carries on — the
25
+ # cache is an optimisation, and the answers are worth more than it.
26
+ attr_reader :cache_error
27
+
28
+ def initialize(users_api:, field:, workspace_name:, cache_store: nil, on_progress: nil)
29
+ @users_api = users_api
30
+ @field = field
31
+ @workspace_name = workspace_name
32
+ @cache = cache_store
33
+ @on_progress = on_progress
34
+ end
35
+
36
+ # @return [Hash{String => String, nil}] user ID => ISO date, or nil for
37
+ # an account with no start date recorded
38
+ def fetch(user_ids)
39
+ # Checked even for an empty list: "this workspace cannot do tenure" is
40
+ # the same fact whether or not anyone matched the filter, and finding
41
+ # out only when someone matches makes it look intermittent.
42
+ raise MissingFieldError, @field.missing_message if @field.id.to_s.empty?
43
+ return {} if user_ids.empty?
44
+
45
+ known = cached
46
+ user_ids.each_with_index { |id, index| resolve(known, id, index, user_ids.size) }
47
+ known.slice(*user_ids)
48
+ end
49
+
50
+ # How many of these would cost an API call, so a caller can warn about
51
+ # the wait before making someone sit through it.
52
+ def uncached_count(user_ids)
53
+ known = cached
54
+ user_ids.count { |id| !known.key?(id) }
55
+ end
56
+
57
+ private
58
+
59
+ def resolve(known, user_id, index, total)
60
+ return if known.key?(user_id)
61
+
62
+ @on_progress&.call(index + 1, total)
63
+ known[user_id] = start_date_for(user_id)
64
+ remember(known)
65
+ end
66
+
67
+ # A full or read-only disk must not throw away a lookup that cost
68
+ # minutes of rate-limited calls. MetaCache.write hands back the failure
69
+ # instead of raising; the caller reports it once at the end.
70
+ def remember(known)
71
+ failure = MetaCache.write(@cache, @workspace_name, CACHE_KEY, known)
72
+ # First failure only: the same unwritable disk will fail every row.
73
+ @cache_error = failure.message if failure && @cache_error.nil?
74
+ end
75
+
76
+ # Which account failed matters when the answer arrives twenty rows into
77
+ # a run that has already taken three minutes.
78
+ def start_date_for(user_id)
79
+ fields = @users_api.profile_for(user_id).dig('profile', 'fields')
80
+ value = fields.is_a?(Hash) ? fields.dig(@field.id, 'value') : nil
81
+ value.to_s.empty? ? nil : value
82
+ rescue ApiError => e
83
+ # Only the plain kind: re-wrapping a RateLimitError would drop its
84
+ # retry_after and the class the retry logic looks for.
85
+ raise unless e.instance_of?(ApiError)
86
+
87
+ raise ApiError.new("#{e.message} (looking up #{user_id})", code: e.code)
88
+ end
89
+
90
+ def cached
91
+ @cached ||= begin
92
+ data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: TTL)
93
+ data.is_a?(Hash) ? data : {}
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.8.0'
4
+ VERSION = '0.10.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'fileutils'
7
7
  require 'optparse'
8
8
  require 'time'
9
+ require 'date'
9
10
  require 'io/console'
10
11
 
11
12
  # Slack CLI - A command-line interface for Slack
@@ -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.8.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