teems 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- metadata +110 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Teems
|
|
6
|
+
module Commands
|
|
7
|
+
OOO_HELP = <<~HELP
|
|
8
|
+
teems ooo - Manage out-of-office status
|
|
9
|
+
|
|
10
|
+
USAGE:
|
|
11
|
+
teems ooo Show current OOO status
|
|
12
|
+
teems ooo on [options] Enable out-of-office
|
|
13
|
+
teems ooo off Disable out-of-office
|
|
14
|
+
teems ooo config Show OOO configuration
|
|
15
|
+
|
|
16
|
+
ON OPTIONS:
|
|
17
|
+
--message TEXT Auto-reply and status message
|
|
18
|
+
--start DATE Schedule start (YYYY-MM-DD), enables scheduled mode
|
|
19
|
+
--end DATE Schedule end (YYYY-MM-DD), required with --start
|
|
20
|
+
--event Create an all-day OOO calendar event for notify list
|
|
21
|
+
--no-status Skip setting Teams status/presence
|
|
22
|
+
|
|
23
|
+
CONFIGURATION:
|
|
24
|
+
Edit ~/.config/teems/config.json to set defaults:
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
"ooo": {
|
|
28
|
+
"internal_message": "I'm currently out of office.",
|
|
29
|
+
"external_message": "Thank you for your message. I'm out of office.",
|
|
30
|
+
"external_audience": "all",
|
|
31
|
+
"status_message": "Out of Office",
|
|
32
|
+
"notify": ["manager@example.com", "team@example.com"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
EXAMPLES:
|
|
37
|
+
teems ooo # Check OOO status
|
|
38
|
+
teems ooo on # Enable OOO (always on)
|
|
39
|
+
teems ooo on --message "Vacation" # Custom message
|
|
40
|
+
teems ooo on --start 2026-04-14 --end 2026-04-18
|
|
41
|
+
teems ooo off # Disable OOO
|
|
42
|
+
teems ooo config # Show config
|
|
43
|
+
HELP
|
|
44
|
+
|
|
45
|
+
# Displays current OOO status (auto-reply + presence)
|
|
46
|
+
module OooDisplay
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def show_status
|
|
50
|
+
replies = fetch_auto_replies
|
|
51
|
+
presence = fetch_presence
|
|
52
|
+
render_ooo_status(replies, presence)
|
|
53
|
+
0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fetch_auto_replies
|
|
57
|
+
with_token_refresh { runner.users_api.auto_replies }
|
|
58
|
+
rescue ApiError => e
|
|
59
|
+
debug("Auto-reply fetch failed: #{e.message}")
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fetch_presence
|
|
64
|
+
with_token_refresh { runner.users_api.my_presence }
|
|
65
|
+
rescue ApiError => e
|
|
66
|
+
debug("Presence fetch failed: #{e.message}")
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_ooo_status(replies, presence)
|
|
71
|
+
if @options[:json]
|
|
72
|
+
output_json({ auto_replies: replies, presence: presence })
|
|
73
|
+
else
|
|
74
|
+
render_ooo_text(replies, presence)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_ooo_text(replies, presence)
|
|
79
|
+
render_auto_reply_status(replies)
|
|
80
|
+
render_presence_status(presence)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_auto_reply_status(replies)
|
|
84
|
+
return puts('Auto-replies: unknown (permission denied)') unless replies
|
|
85
|
+
|
|
86
|
+
status = replies['status'] || 'disabled'
|
|
87
|
+
puts "Auto-replies: #{status}"
|
|
88
|
+
render_schedule(replies) if status == 'scheduled'
|
|
89
|
+
render_reply_message(replies)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_schedule(replies)
|
|
93
|
+
start_dt = replies.dig('scheduledStartDateTime', 'dateTime')
|
|
94
|
+
end_dt = replies.dig('scheduledEndDateTime', 'dateTime')
|
|
95
|
+
puts " Schedule: #{format_dt(start_dt)} to #{format_dt(end_dt)}" if start_dt
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def format_dt(raw)
|
|
99
|
+
Time.parse(raw).strftime('%Y-%m-%d %H:%M')
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
raw.to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_reply_message(replies)
|
|
105
|
+
plain = strip_reply_html(replies['internalReplyMessage'])
|
|
106
|
+
puts " Message: #{plain[0..80]}" unless plain.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def strip_reply_html(raw) = raw.to_s.gsub(/<[^>]+>/, '').strip
|
|
110
|
+
|
|
111
|
+
def render_presence_status(presence)
|
|
112
|
+
return unless presence
|
|
113
|
+
|
|
114
|
+
availability = presence['availability'] || 'Unknown'
|
|
115
|
+
puts "Presence: #{availability}"
|
|
116
|
+
msg = presence.dig('statusMessage', 'message', 'content')
|
|
117
|
+
puts " Status: #{msg}" if msg && !msg.empty?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Enables/disables OOO: auto-reply, status message, presence, calendar event
|
|
122
|
+
module OooActions
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def enable_ooo
|
|
126
|
+
result = validate_create_options
|
|
127
|
+
return result if result
|
|
128
|
+
|
|
129
|
+
results = execute_ooo_steps
|
|
130
|
+
summarize_results(results)
|
|
131
|
+
results.values.all? ? 0 : 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def disable_ooo
|
|
135
|
+
results = {}
|
|
136
|
+
results[:auto_reply] = disable_auto_reply
|
|
137
|
+
results[:status] = clear_ooo_status unless @options[:no_status]
|
|
138
|
+
summarize_disable_results(results)
|
|
139
|
+
results.values.all? ? 0 : 1
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_create_options
|
|
143
|
+
validate_schedule_dates || validate_date_format(:start) || validate_date_format(:end)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_schedule_dates
|
|
147
|
+
return unless @options[:start] && !@options[:end]
|
|
148
|
+
|
|
149
|
+
error('--end is required when using --start')
|
|
150
|
+
1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_date_format(key)
|
|
154
|
+
value = @options[key]
|
|
155
|
+
return unless value
|
|
156
|
+
|
|
157
|
+
Date.parse(value)
|
|
158
|
+
nil
|
|
159
|
+
rescue ArgumentError
|
|
160
|
+
error("Invalid date for --#{key}: #{value}. Expected format: YYYY-MM-DD")
|
|
161
|
+
1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def execute_ooo_steps
|
|
165
|
+
results = {}
|
|
166
|
+
results[:auto_reply] = set_auto_reply
|
|
167
|
+
results[:status] = set_ooo_status unless @options[:no_status]
|
|
168
|
+
results[:event] = create_ooo_event if @options[:event] && !notify_list.empty?
|
|
169
|
+
results
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def set_auto_reply
|
|
173
|
+
settings = build_auto_reply_settings
|
|
174
|
+
with_token_refresh { runner.users_api.update_auto_replies(settings) }
|
|
175
|
+
success('Auto-reply enabled')
|
|
176
|
+
true
|
|
177
|
+
rescue ApiError => e
|
|
178
|
+
warn_step('auto-reply', e)
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def disable_auto_reply
|
|
183
|
+
with_token_refresh { runner.users_api.update_auto_replies(status: 'disabled') }
|
|
184
|
+
success('Auto-reply disabled')
|
|
185
|
+
true
|
|
186
|
+
rescue ApiError => e
|
|
187
|
+
warn_step('auto-reply', e)
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def set_ooo_status
|
|
192
|
+
message = status_message_text
|
|
193
|
+
apply_ooo_presence(message)
|
|
194
|
+
success("Status set: #{message}")
|
|
195
|
+
true
|
|
196
|
+
rescue ApiError => e
|
|
197
|
+
warn_step('status/presence', e)
|
|
198
|
+
false
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def apply_ooo_presence(message)
|
|
202
|
+
users = runner.users_api
|
|
203
|
+
with_token_refresh { users.set_status_message(message: message, expiry: nil) }
|
|
204
|
+
with_token_refresh { users.set_presence(availability: 'Offline', activity: 'OffWork', duration: 'PT8H') }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def clear_ooo_status
|
|
208
|
+
users = runner.users_api
|
|
209
|
+
with_token_refresh { users.clear_status_message }
|
|
210
|
+
with_token_refresh { users.clear_presence }
|
|
211
|
+
success('Status and presence cleared')
|
|
212
|
+
true
|
|
213
|
+
rescue ApiError => e
|
|
214
|
+
warn_step('status/presence', e)
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def create_ooo_event
|
|
219
|
+
body = build_ooo_event
|
|
220
|
+
with_token_refresh { runner.calendar_api.create_event(body) }
|
|
221
|
+
success("Calendar event created for #{notify_list.length} recipient(s)")
|
|
222
|
+
true
|
|
223
|
+
rescue ApiError => e
|
|
224
|
+
warn_step('calendar event', e)
|
|
225
|
+
false
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def warn_step(step, err)
|
|
229
|
+
output.warn("#{step}: #{err.message}")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Builds API request bodies for OOO operations
|
|
234
|
+
module OooBuildHelpers
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def build_auto_reply_settings
|
|
238
|
+
msg = auto_reply_message
|
|
239
|
+
settings = { status: auto_reply_status,
|
|
240
|
+
internalReplyMessage: msg,
|
|
241
|
+
externalReplyMessage: external_message(msg),
|
|
242
|
+
externalAudience: ooo_config('external_audience') || 'all' }
|
|
243
|
+
add_schedule(settings) if @options[:start]
|
|
244
|
+
settings
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def auto_reply_status
|
|
248
|
+
@options[:start] ? 'scheduled' : 'alwaysEnabled'
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def auto_reply_message
|
|
252
|
+
@options[:message] || ooo_config('internal_message') || 'I am currently out of office.'
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def external_message(internal_fallback)
|
|
256
|
+
ooo_config('external_message') || internal_fallback
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def status_message_text
|
|
260
|
+
@options[:message] || ooo_config('status_message') || 'Out of Office'
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def add_schedule(settings)
|
|
264
|
+
tz = detect_timezone
|
|
265
|
+
settings[:scheduledStartDateTime] = { dateTime: "#{@options[:start]}T00:00:00", timeZone: tz }
|
|
266
|
+
settings[:scheduledEndDateTime] = { dateTime: "#{@options[:end]}T23:59:59", timeZone: tz }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def build_ooo_event
|
|
270
|
+
{ subject: status_message_text,
|
|
271
|
+
start: ooo_event_time(:start),
|
|
272
|
+
end: ooo_event_time(:end),
|
|
273
|
+
isAllDay: true,
|
|
274
|
+
showAs: 'free',
|
|
275
|
+
isReminderOn: false,
|
|
276
|
+
isOnlineMeeting: false,
|
|
277
|
+
transactionId: SecureRandom.uuid,
|
|
278
|
+
attendees: notify_list.map { |email| ooo_attendee(email) },
|
|
279
|
+
responseRequested: false }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def ooo_event_time(field)
|
|
283
|
+
date = @options[field] || Date.today.to_s
|
|
284
|
+
end_date = field == :end ? (Date.parse(date) + 1).to_s : date
|
|
285
|
+
tz = detect_timezone
|
|
286
|
+
{ dateTime: "#{end_date}T00:00:00", timeZone: tz }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def ooo_attendee(email)
|
|
290
|
+
{ emailAddress: { address: email }, type: 'required' }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def notify_list
|
|
294
|
+
@notify_list ||= ooo_config('notify') || []
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def ooo_config(key)
|
|
298
|
+
config['ooo']&.dig(key)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Manage out-of-office: auto-reply, status, presence, and calendar event
|
|
303
|
+
class Ooo < Base
|
|
304
|
+
include Support::Timezone
|
|
305
|
+
include OooDisplay
|
|
306
|
+
include OooActions
|
|
307
|
+
include OooBuildHelpers
|
|
308
|
+
|
|
309
|
+
def initialize(args, runner:)
|
|
310
|
+
@options = {}
|
|
311
|
+
super
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
OOO_OPTIONS = {
|
|
315
|
+
'--message' => ->(opts, args) { opts[:message] = args.shift },
|
|
316
|
+
'--start' => ->(opts, args) { opts[:start] = args.shift },
|
|
317
|
+
'--end' => ->(opts, args) { opts[:end] = args.shift },
|
|
318
|
+
'--event' => ->(opts, _args) { opts[:event] = true },
|
|
319
|
+
'--no-status' => ->(opts, _args) { opts[:no_status] = true }
|
|
320
|
+
}.freeze
|
|
321
|
+
|
|
322
|
+
OOO_ACTIONS = {
|
|
323
|
+
'on' => :enable_ooo, 'off' => :disable_ooo,
|
|
324
|
+
'config' => :show_config
|
|
325
|
+
}.freeze
|
|
326
|
+
|
|
327
|
+
def execute
|
|
328
|
+
result = validate_options
|
|
329
|
+
return result if result
|
|
330
|
+
|
|
331
|
+
auth_result = require_auth
|
|
332
|
+
return auth_result if auth_result
|
|
333
|
+
|
|
334
|
+
dispatch_action
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
protected
|
|
338
|
+
|
|
339
|
+
def handle_option(arg, pending)
|
|
340
|
+
handler = OOO_OPTIONS[arg]
|
|
341
|
+
return super unless handler
|
|
342
|
+
|
|
343
|
+
handler.call(@options, pending)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def help_text = OOO_HELP
|
|
347
|
+
|
|
348
|
+
private
|
|
349
|
+
|
|
350
|
+
def dispatch_action
|
|
351
|
+
action = positional_args.first
|
|
352
|
+
method_name = OOO_ACTIONS[action]
|
|
353
|
+
return send(method_name) if method_name
|
|
354
|
+
return show_status unless action
|
|
355
|
+
|
|
356
|
+
error("Unknown action: #{action}. Use: on, off, config")
|
|
357
|
+
1
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def show_config
|
|
361
|
+
ooo = config['ooo'] || {}
|
|
362
|
+
if @options[:json]
|
|
363
|
+
output_json(ooo)
|
|
364
|
+
else
|
|
365
|
+
puts ooo.empty? ? 'No OOO config set. See: teems ooo --help' : JSON.pretty_generate(ooo)
|
|
366
|
+
end
|
|
367
|
+
0
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def summarize_results(results)
|
|
371
|
+
return if results.values.all?
|
|
372
|
+
|
|
373
|
+
output.warn("OOO partially enabled: #{count_successes(results)}/#{results.size} steps succeeded")
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def summarize_disable_results(results)
|
|
377
|
+
return if results.values.all?
|
|
378
|
+
|
|
379
|
+
output.warn("OOO partially disabled: #{count_successes(results)}/#{results.size} steps succeeded")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def count_successes(results) = results.values.count(true)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# Walks manager chain and direct reports tree
|
|
6
|
+
module OrgTreeWalker
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def walk_manager_chain(target_id)
|
|
10
|
+
managers = []
|
|
11
|
+
current_id = target_id
|
|
12
|
+
fetch = method(target_is_me? ? :fetch_my_manager : :fetch_user_manager)
|
|
13
|
+
collect_managers(managers, current_id, fetch)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def collect_managers(managers, current_id, fetch)
|
|
17
|
+
while (mgr = fetch.call(current_id))
|
|
18
|
+
managers.unshift(mgr)
|
|
19
|
+
current_id = mgr.id
|
|
20
|
+
fetch = method(:fetch_user_manager)
|
|
21
|
+
end
|
|
22
|
+
managers
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch_my_manager(_user_id)
|
|
26
|
+
with_token_refresh { runner.users_api.manager_me }
|
|
27
|
+
rescue ApiError => e
|
|
28
|
+
raise unless e.not_found?
|
|
29
|
+
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch_user_manager(user_id)
|
|
34
|
+
with_token_refresh { runner.users_api.manager(user_id) }
|
|
35
|
+
rescue ApiError => e
|
|
36
|
+
raise unless e.not_found?
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def walk_reports_tree(user, remaining_depth, fetch)
|
|
42
|
+
return { user: user, reports: [] } if remaining_depth <= 0
|
|
43
|
+
|
|
44
|
+
reports = fetch.call(user.id)
|
|
45
|
+
children = reports.map { |report| walk_reports_tree(report, remaining_depth - 1, method(:fetch_user_reports)) }
|
|
46
|
+
{ user: user, reports: children }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch_my_reports(_user_id)
|
|
50
|
+
with_token_refresh { runner.users_api.direct_reports_me }
|
|
51
|
+
rescue ApiError => e
|
|
52
|
+
handle_reports_error(e, 'direct reports')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch_user_reports(user_id)
|
|
56
|
+
with_token_refresh { runner.users_api.direct_reports(user_id) }
|
|
57
|
+
rescue ApiError => e
|
|
58
|
+
handle_reports_error(e, "direct reports for #{user_id}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_reports_error(err, context)
|
|
62
|
+
debug("Could not fetch #{context}: #{err.message}")
|
|
63
|
+
raise unless recoverable_reports_error?(err)
|
|
64
|
+
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def recoverable_reports_error?(err) = err.not_found? || err.forbidden?
|
|
69
|
+
|
|
70
|
+
def target_is_me?
|
|
71
|
+
positional_args.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Renders org chart as text tree or JSON
|
|
76
|
+
module OrgRenderer
|
|
77
|
+
# Groups org chart data: managers chain, target user, and reports tree
|
|
78
|
+
OrgData = Struct.new(:managers, :target, :reports) do
|
|
79
|
+
def to_json_hash
|
|
80
|
+
{ managers: managers.map(&:to_h), target: target.to_h,
|
|
81
|
+
direct_reports: self.class.json_reports(reports[:reports]) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.json_reports(reports)
|
|
85
|
+
reports.map { |report| json_report(*report.values_at(:user, :reports)) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.json_report(user, sub_reports)
|
|
89
|
+
user.to_h.merge(direct_reports: json_reports(sub_reports))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def render_org(org_data)
|
|
96
|
+
if @options[:json]
|
|
97
|
+
output_json(org_data.to_json_hash)
|
|
98
|
+
else
|
|
99
|
+
render_tree(org_data)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_tree(org_data)
|
|
104
|
+
managers = org_data.managers
|
|
105
|
+
depth = managers.length
|
|
106
|
+
managers.each_with_index { |mgr, index| puts "#{' ' * index}#{format_person(mgr)}" }
|
|
107
|
+
puts "#{' ' * depth}--> #{format_person(org_data.target)}"
|
|
108
|
+
render_reports(org_data.reports[:reports], depth + 1)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_reports(reports, level)
|
|
112
|
+
reports.each do |node|
|
|
113
|
+
puts "#{' ' * level}#{format_person(node[:user])}"
|
|
114
|
+
render_reports(node[:reports], level + 1)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def format_person(profile)
|
|
119
|
+
title = profile.job_title
|
|
120
|
+
name = profile.best_name
|
|
121
|
+
title && !title.empty? ? "#{name} (#{title})" : name.to_s
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Show org chart for a user
|
|
126
|
+
class Org < Base
|
|
127
|
+
include OrgTreeWalker
|
|
128
|
+
include OrgRenderer
|
|
129
|
+
|
|
130
|
+
ORG_OPTIONS = {
|
|
131
|
+
'--depth' => ->(opts, args) { opts[:depth] = parse_depth(args.shift) }
|
|
132
|
+
}.freeze
|
|
133
|
+
|
|
134
|
+
def self.parse_depth(value)
|
|
135
|
+
return 1 unless value
|
|
136
|
+
|
|
137
|
+
result = value.to_i
|
|
138
|
+
result >= 0 ? result : 1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def initialize(args, runner:)
|
|
142
|
+
@options = {}
|
|
143
|
+
super
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def execute
|
|
147
|
+
result = validate_options
|
|
148
|
+
return result if result
|
|
149
|
+
|
|
150
|
+
auth_result = require_auth
|
|
151
|
+
return auth_result if auth_result
|
|
152
|
+
|
|
153
|
+
show_org_chart
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
protected
|
|
157
|
+
|
|
158
|
+
def handle_option(arg, pending)
|
|
159
|
+
handler = ORG_OPTIONS[arg]
|
|
160
|
+
return super unless handler
|
|
161
|
+
|
|
162
|
+
handler.call(@options, pending)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def help_text
|
|
166
|
+
<<~HELP
|
|
167
|
+
#{output.bold('teems org')} - Show org chart for a user
|
|
168
|
+
|
|
169
|
+
#{output.bold('USAGE:')}
|
|
170
|
+
teems org [options] Org chart for current user
|
|
171
|
+
teems org <query> [options] Org chart for a searched user
|
|
172
|
+
|
|
173
|
+
#{output.bold('OPTIONS:')}
|
|
174
|
+
--depth N Limit direct report depth (default: 1)
|
|
175
|
+
-v, --verbose Show debug output
|
|
176
|
+
-q, --quiet Suppress output
|
|
177
|
+
--json Output as JSON
|
|
178
|
+
-h, --help Show this help
|
|
179
|
+
|
|
180
|
+
#{output.bold('EXAMPLES:')}
|
|
181
|
+
teems org # Your org chart
|
|
182
|
+
teems org john # Org chart for "john"
|
|
183
|
+
teems org --depth 1 # Only immediate reports
|
|
184
|
+
teems org --json # JSON output
|
|
185
|
+
HELP
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def show_org_chart
|
|
191
|
+
target = resolve_target
|
|
192
|
+
target ? display_org(target) : 1
|
|
193
|
+
rescue ApiError => e
|
|
194
|
+
org_fetch_error(e)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def org_fetch_error(err)
|
|
198
|
+
error("Failed to fetch org chart: #{err.message}")
|
|
199
|
+
1
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def display_org(target)
|
|
203
|
+
render_org(fetch_org_data(target))
|
|
204
|
+
0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def fetch_org_data(target)
|
|
208
|
+
managers = walk_manager_chain(target.id)
|
|
209
|
+
report_fetch = method(target_is_me? ? :fetch_my_reports : :fetch_user_reports)
|
|
210
|
+
reports = walk_reports_tree(target, depth, report_fetch)
|
|
211
|
+
OrgRenderer::OrgData.new(managers: managers, target: target, reports: reports)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def depth
|
|
215
|
+
@options[:depth] || 1
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def resolve_target
|
|
219
|
+
query = positional_args.join(' ')
|
|
220
|
+
query.empty? ? with_token_refresh { runner.users_api.me } : resolve_by_search(query)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def resolve_by_search(query)
|
|
224
|
+
results = with_token_refresh { runner.users_api.search(query) }
|
|
225
|
+
return results.first unless results.empty?
|
|
226
|
+
|
|
227
|
+
error("No users found matching '#{query}'")
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|