legion-tty 0.2.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,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Legion
8
+ module TTY
9
+ module Background
10
+ # rubocop:disable Metrics/ClassLength
11
+ class GitHubProbe
12
+ API_BASE = 'https://api.github.com'
13
+ USER_AGENT = 'legion-tty/github-probe'
14
+
15
+ def initialize(token: nil, logger: nil)
16
+ @log = logger
17
+ @token = token || resolve_token
18
+ end
19
+
20
+ # rubocop:disable Metrics/AbcSize
21
+ def run_quick_async(queue)
22
+ Thread.new do
23
+ unless @token
24
+ @log&.log('github', 'quick probe skipped (no token)')
25
+ queue.push({ type: :github_quick_complete, data: nil })
26
+ return
27
+ end
28
+
29
+ @log&.log('github', 'quick probe: fetching /user + recent commits')
30
+ t0 = Time.now
31
+ result = fetch_quick_profile
32
+ elapsed = ((Time.now - t0) * 1000).round
33
+ @log&.log('github', "quick probe complete in #{elapsed}ms")
34
+ queue.push({ type: :github_quick_complete, data: result })
35
+ rescue StandardError => e
36
+ @log&.log('github', "quick probe ERROR: #{e.class}: #{e.message}")
37
+ queue.push({ type: :github_quick_error, error: e.message })
38
+ end
39
+ end
40
+ # rubocop:enable Metrics/AbcSize
41
+
42
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
43
+ def run_async(queue, remotes: [], quick_profile: nil)
44
+ Thread.new do
45
+ @log&.log('github', "probing with #{remotes.size} remotes: #{remotes.first(5).inspect}")
46
+ @log&.log('github', "token: #{@token ? 'present' : 'NONE'}")
47
+ @log&.log('github', "quick_profile: #{quick_profile ? 'reusing' : 'none'}")
48
+ t0 = Time.now
49
+ result = if @token
50
+ build_authenticated_result(remotes, quick_profile: quick_profile)
51
+ else
52
+ build_public_result(remotes)
53
+ end
54
+ elapsed = ((Time.now - t0) * 1000).round
55
+ @log&.log('github', "probe complete in #{elapsed}ms")
56
+ queue.push({ type: :github_probe_complete, data: result })
57
+ rescue StandardError => e
58
+ @log&.log('github', "ERROR: #{e.class}: #{e.message}")
59
+ queue.push({ type: :github_error, error: e.message })
60
+ end
61
+ end
62
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
63
+
64
+ private
65
+
66
+ # --- Quick profile: just /user + commit count (runs during rain) ---
67
+
68
+ # rubocop:disable Metrics/AbcSize
69
+ def fetch_quick_profile
70
+ user_data = api_get('/user')
71
+ return nil unless user_data.is_a?(Hash) && user_data['login']
72
+
73
+ username = user_data['login']
74
+ @log&.log('github', "quick: authenticated as #{username}")
75
+
76
+ week_ago = (Time.now - (7 * 86_400)).strftime('%Y-%m-%d')
77
+ month_ago = (Time.now - (30 * 86_400)).strftime('%Y-%m-%d')
78
+
79
+ commits_week = count_commits(username, since: week_ago)
80
+ commits_month = count_commits(username, since: month_ago)
81
+ @log&.log('github', "quick: commits this week=#{commits_week} this month=#{commits_month}")
82
+
83
+ {
84
+ username: username,
85
+ name: user_data['name'],
86
+ public_repos: user_data['public_repos'],
87
+ private_repos: user_data['total_private_repos'],
88
+ followers: user_data['followers'],
89
+ created_at: user_data['created_at'],
90
+ commits_this_week: commits_week,
91
+ commits_this_month: commits_month
92
+ }
93
+ end
94
+ # rubocop:enable Metrics/AbcSize
95
+
96
+ def count_commits(username, since:)
97
+ query = URI.encode_www_form_component("author:#{username} committer-date:>#{since}")
98
+ uri = URI("#{API_BASE}/search/commits?q=#{query}&per_page=1")
99
+ http = build_http(uri)
100
+ request = build_request(uri)
101
+ request['Accept'] = 'application/vnd.github.cloak-preview+json'
102
+ response = http.request(request)
103
+ data = ::JSON.parse(response.body)
104
+ return 0 unless data.is_a?(Hash)
105
+
106
+ data['total_count'] || 0
107
+ rescue StandardError
108
+ 0
109
+ end
110
+
111
+ # --- Authenticated path: GET /user tells us who we are ---
112
+
113
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
114
+ def build_authenticated_result(remotes, quick_profile: nil)
115
+ if quick_profile
116
+ username = quick_profile[:username]
117
+ @log&.log('github', "reusing quick profile for: #{username}")
118
+ profile = quick_profile
119
+ else
120
+ user_data = api_get('/user')
121
+ unless user_data.is_a?(Hash) && user_data['login']
122
+ @log&.log('github', 'authenticated /user failed, falling back to public')
123
+ return build_public_result(remotes)
124
+ end
125
+ username = user_data['login']
126
+ @log&.log('github', "authenticated as: #{username}")
127
+ profile = extract_profile(user_data)
128
+ end
129
+ orgs = fetch_orgs
130
+ private_repos = fetch_private_repos
131
+ public_repos = fetch_public_repos(username)
132
+ recent_prs = fetch_recent_prs(username)
133
+ events = fetch_recent_events(username)
134
+ notifications = fetch_notifications
135
+ starred = fetch_starred
136
+
137
+ {
138
+ username: username,
139
+ authenticated: true,
140
+ profile: profile,
141
+ orgs: orgs,
142
+ private_repos: private_repos,
143
+ public_repos: public_repos,
144
+ recent_prs: recent_prs,
145
+ events: events,
146
+ notifications: notifications,
147
+ starred: starred
148
+ }
149
+ end
150
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
151
+
152
+ def extract_profile(data)
153
+ {
154
+ login: data['login'],
155
+ name: data['name'],
156
+ bio: data['bio'],
157
+ public_repos: data['public_repos'],
158
+ private_repos: data['total_private_repos'],
159
+ company: data['company'],
160
+ location: data['location'],
161
+ email: data['email'],
162
+ created_at: data['created_at'],
163
+ followers: data['followers'],
164
+ following: data['following']
165
+ }
166
+ end
167
+
168
+ def fetch_orgs
169
+ data = api_get('/user/orgs')
170
+ return [] unless data.is_a?(Array)
171
+
172
+ data.map { |o| { login: o['login'], description: o['description'] } }
173
+ end
174
+
175
+ def fetch_private_repos(limit: 10)
176
+ data = api_get("/user/repos?visibility=private&sort=updated&per_page=#{limit}")
177
+ return [] unless data.is_a?(Array)
178
+
179
+ data.map { |r| extract_repo(r) }
180
+ end
181
+
182
+ # rubocop:disable Metrics/AbcSize
183
+ def fetch_recent_prs(username, limit: 10)
184
+ query = URI.encode_www_form_component("author:#{username} type:pr sort:updated")
185
+ data = api_get("/search/issues?q=#{query}&per_page=#{limit}")
186
+ return [] unless data.is_a?(Hash) && data['items'].is_a?(Array)
187
+
188
+ data['items'].map do |pr|
189
+ repo_url = pr['repository_url']
190
+ repo_name = repo_url ? repo_url.split('/').last(2).join('/') : nil
191
+ {
192
+ title: pr['title'],
193
+ repo: repo_name,
194
+ state: pr['state'],
195
+ created_at: pr['created_at'],
196
+ updated_at: pr['updated_at'],
197
+ url: pr['html_url']
198
+ }
199
+ end
200
+ end
201
+ # rubocop:enable Metrics/AbcSize
202
+
203
+ def fetch_notifications(limit: 10)
204
+ data = api_get("/notifications?per_page=#{limit}")
205
+ return [] unless data.is_a?(Array)
206
+
207
+ data.map do |n|
208
+ {
209
+ reason: n['reason'],
210
+ title: n.dig('subject', 'title'),
211
+ type: n.dig('subject', 'type'),
212
+ repo: n.dig('repository', 'full_name'),
213
+ updated_at: n['updated_at']
214
+ }
215
+ end
216
+ end
217
+
218
+ def fetch_starred(limit: 10)
219
+ data = api_get("/user/starred?per_page=#{limit}&sort=created&direction=desc")
220
+ return [] unless data.is_a?(Array)
221
+
222
+ data.map { |r| extract_repo(r) }
223
+ end
224
+
225
+ # --- Public path: infer username from remotes ---
226
+
227
+ def build_public_result(remotes)
228
+ username = remotes.filter_map { |r| infer_username(r) }.first
229
+ @log&.log('github', "inferred username: #{username || 'none'}")
230
+ return { username: nil } unless username
231
+
232
+ profile_data = api_get("/users/#{username}")
233
+ profile = profile_data.is_a?(Hash) ? extract_profile(profile_data) : nil
234
+
235
+ {
236
+ username: username,
237
+ authenticated: false,
238
+ profile: profile,
239
+ public_repos: fetch_public_repos(username),
240
+ events: fetch_recent_events(username)
241
+ }
242
+ end
243
+
244
+ def infer_username(remote_url)
245
+ return nil if remote_url.nil? || remote_url.empty?
246
+
247
+ match = remote_url.match(%r{github\.com[:/]([^/]+)/})
248
+ match ? match[1] : nil
249
+ end
250
+
251
+ # --- Shared fetchers ---
252
+
253
+ def fetch_public_repos(username, limit: 10)
254
+ data = api_get("/users/#{username}/repos?sort=updated&per_page=#{limit}")
255
+ return [] unless data.is_a?(Array)
256
+
257
+ data.map { |r| extract_repo(r) }
258
+ end
259
+
260
+ def fetch_recent_events(username, limit: 10)
261
+ data = api_get("/users/#{username}/events/public?per_page=#{limit}")
262
+ return [] unless data.is_a?(Array)
263
+
264
+ data.first(limit).map do |e|
265
+ {
266
+ type: e['type'],
267
+ repo: e.dig('repo', 'name'),
268
+ created_at: e['created_at']
269
+ }
270
+ end
271
+ end
272
+
273
+ def extract_repo(repo)
274
+ {
275
+ full_name: repo['full_name'],
276
+ language: repo['language'],
277
+ private: repo['private'],
278
+ updated_at: repo['updated_at'],
279
+ description: repo['description']
280
+ }
281
+ end
282
+
283
+ # --- Token resolution ---
284
+
285
+ # rubocop:disable Metrics/CyclomaticComplexity
286
+ def resolve_token
287
+ env_token = ENV.fetch('GITHUB_TOKEN', nil) ||
288
+ ENV.fetch('GH_TOKEN', nil) ||
289
+ ENV.fetch('GITHUB_PERSONAL_ACCESS_TOKEN', nil)
290
+ if env_token
291
+ @log&.log('github', 'token source: environment variable')
292
+ return env_token
293
+ end
294
+
295
+ gh_token = token_from_gh_cli
296
+ if gh_token
297
+ @log&.log('github', 'token source: gh CLI')
298
+ return gh_token
299
+ end
300
+
301
+ @log&.log('github', 'no token found (no env var, no gh CLI)')
302
+ nil
303
+ end
304
+ # rubocop:enable Metrics/CyclomaticComplexity
305
+
306
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
307
+ def token_from_gh_cli
308
+ gh_path = `which gh 2>/dev/null`.strip
309
+ return nil if gh_path.empty?
310
+
311
+ @log&.log('github', "found gh CLI at #{gh_path}")
312
+
313
+ status = `gh auth status 2>&1`
314
+ @log&.log('github', "gh auth status: #{status.lines.first&.strip}")
315
+ return nil unless $CHILD_STATUS&.success?
316
+
317
+ token = `gh auth token 2>/dev/null`.strip
318
+ return nil if token.empty?
319
+
320
+ token
321
+ rescue StandardError => e
322
+ @log&.log('github', "gh CLI error: #{e.message}")
323
+ nil
324
+ end
325
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
326
+
327
+ # --- HTTP ---
328
+
329
+ def api_get(path)
330
+ uri = URI("#{API_BASE}#{path}")
331
+ http = build_http(uri)
332
+ response = http.request(build_request(uri))
333
+ ::JSON.parse(response.body)
334
+ rescue StandardError
335
+ nil
336
+ end
337
+
338
+ def build_http(uri)
339
+ http = ::Net::HTTP.new(uri.host, uri.port)
340
+ http.use_ssl = true
341
+ http.open_timeout = 3
342
+ http.read_timeout = 5
343
+ http
344
+ end
345
+
346
+ def build_request(uri)
347
+ request = ::Net::HTTP::Get.new(uri)
348
+ request['User-Agent'] = USER_AGENT
349
+ request['Accept'] = 'application/vnd.github+json'
350
+ request['Authorization'] = "Bearer #{@token}" if @token
351
+ request
352
+ end
353
+ end
354
+ # rubocop:enable Metrics/ClassLength
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'shellwords'
5
+
6
+ module Legion
7
+ module TTY
8
+ module Background
9
+ # rubocop:disable Metrics/ClassLength
10
+ class KerberosProbe
11
+ def initialize(logger: nil)
12
+ @log = logger
13
+ end
14
+
15
+ def probe
16
+ principal = read_principal
17
+ return nil unless principal
18
+
19
+ username = principal.split('@', 2).first
20
+ realm = principal.include?('@') ? principal.split('@', 2).last : nil
21
+ profile = ldap_profile(username, realm)
22
+
23
+ build_result(principal: principal, username: username, realm: realm, profile: profile)
24
+ end
25
+
26
+ def run_async(queue)
27
+ Thread.new do
28
+ @log&.log('kerberos', 'running klist...')
29
+ t0 = Time.now
30
+ result = probe
31
+ elapsed = ((Time.now - t0) * 1000).round
32
+ log_result(result, elapsed)
33
+ queue.push(type: :kerberos_complete, data: result)
34
+ rescue StandardError => e
35
+ @log&.log('kerberos', "ERROR: #{e.class}: #{e.message}")
36
+ queue.push(type: :kerberos_error, error: e.message)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def read_principal
43
+ output = `klist 2>/dev/null`
44
+ return nil unless $CHILD_STATUS&.success?
45
+
46
+ match = output.match(/Principal:\s+(\S+)/i)
47
+ match&.[](1)&.strip
48
+ end
49
+
50
+ def ldap_profile(username, realm)
51
+ @log&.log('kerberos', "ldap_profile called: username=#{username} realm=#{realm.inspect}")
52
+ return {} unless realm
53
+
54
+ dc_host = discover_dc(realm)
55
+ unless dc_host
56
+ @log&.log('kerberos', "no domain controller found for #{realm}")
57
+ return {}
58
+ end
59
+
60
+ base_dn = realm_to_base_dn(realm)
61
+ @log&.log('kerberos', "LDAP lookup: #{username} via #{dc_host} base=#{base_dn}")
62
+ query_ldap(username: username, host: dc_host, base_dn: base_dn)
63
+ end
64
+
65
+ # rubocop:disable Metrics/CyclomaticComplexity
66
+ def discover_dc(realm)
67
+ domain = realm.downcase
68
+ srv_name = "_ldap._tcp.#{domain}"
69
+ records = Resolv::DNS.open { |dns| dns.getresources(srv_name, Resolv::DNS::Resource::IN::SRV) }
70
+ host = records.min_by(&:priority)&.target&.to_s
71
+ @log&.log('kerberos', "SRV #{srv_name} -> #{host || 'none'} (#{records.size} records)")
72
+ host
73
+ rescue StandardError => e
74
+ @log&.log('kerberos', "SRV lookup failed: #{e.message}")
75
+ nil
76
+ end
77
+ # rubocop:enable Metrics/CyclomaticComplexity
78
+
79
+ def realm_to_base_dn(realm)
80
+ realm.downcase.split('.').map { |part| "DC=#{part}" }.join(',')
81
+ end
82
+
83
+ def query_ldap(username:, host:, base_dn:)
84
+ output = run_ldapsearch(username: username, host: host, base_dn: base_dn)
85
+ return {} unless output
86
+
87
+ profile = parse_ldif(output)
88
+ @log&.log('kerberos', "LDAP profile: #{profile.inspect}")
89
+ profile
90
+ rescue StandardError => e
91
+ @log&.log('kerberos', "LDAP error: #{e.class}: #{e.message}")
92
+ {}
93
+ end
94
+
95
+ def run_ldapsearch(username:, host:, base_dn:)
96
+ cmd = "ldapsearch -H ldap://#{host} -b #{base_dn.shellescape} " \
97
+ "\"(sAMAccountName=#{username.shellescape})\" " \
98
+ 'givenName sn mail displayName title department company ' \
99
+ 'l st co whenCreated 2>/dev/null'
100
+ output = `#{cmd}`
101
+ return nil unless $CHILD_STATUS&.success?
102
+
103
+ output
104
+ end
105
+
106
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
107
+ def parse_ldif(output)
108
+ attrs = {}
109
+ output.each_line do |line|
110
+ line = line.chomp
111
+ case line
112
+ when /\AgivenName:\s*(.+)/ then attrs[:first_name] = Regexp.last_match(1).strip
113
+ when /\Asn:\s*(.+)/ then attrs[:last_name] = Regexp.last_match(1).strip
114
+ when /\Amail:\s*(.+)/ then attrs[:email] = Regexp.last_match(1).strip
115
+ when /\AdisplayName:\s*(.+)/ then attrs[:display_name] = Regexp.last_match(1).strip
116
+ when /\Atitle:\s*(.+)/ then attrs[:title] = Regexp.last_match(1).strip
117
+ when /\Adepartment:\s*(.+)/ then attrs[:department] = Regexp.last_match(1).strip
118
+ when /\Acompany:\s*(.+)/ then attrs[:company] = Regexp.last_match(1).strip
119
+ when /\Al:\s*(.+)/ then attrs[:city] = Regexp.last_match(1).strip
120
+ when /\Ast:\s*(.+)/ then attrs[:state] = Regexp.last_match(1).strip
121
+ when /\Aco:\s*(.+)/ then attrs[:country] = Regexp.last_match(1).strip
122
+ when /\AwhenCreated:\s*(.+)/ then attrs[:when_created] = Regexp.last_match(1).strip
123
+ end
124
+ end
125
+ attrs
126
+ end
127
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
128
+
129
+ def build_result(principal:, username:, realm:, profile:)
130
+ first = profile[:first_name] || username.capitalize
131
+ last = profile[:last_name]
132
+ display = profile[:display_name] || [first, last].compact.join(' ')
133
+
134
+ {
135
+ principal: principal,
136
+ username: username,
137
+ realm: realm,
138
+ first_name: first,
139
+ last_name: last,
140
+ email: profile[:email],
141
+ display_name: display,
142
+ title: profile[:title],
143
+ department: profile[:department],
144
+ company: profile[:company],
145
+ city: profile[:city],
146
+ state: profile[:state],
147
+ country: profile[:country],
148
+ tenure_years: calculate_tenure(profile[:when_created])
149
+ }.compact
150
+ end
151
+
152
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
153
+ def calculate_tenure(when_created)
154
+ return nil unless when_created&.length&.>=(8)
155
+
156
+ # AD generalized time: 20170505044456.0Z
157
+ start_year = when_created[0, 4].to_i
158
+ start_month = when_created[4, 2].to_i
159
+ start_day = when_created[6, 2].to_i
160
+ return nil if start_year.zero?
161
+
162
+ now = Time.now
163
+ days = now.day - start_day
164
+ months = now.month - start_month
165
+ years = now.year - start_year
166
+
167
+ if days.negative?
168
+ months -= 1
169
+ prev_month = now.month - 1
170
+ prev_year = now.year
171
+ if prev_month.zero?
172
+ prev_month = 12
173
+ prev_year -= 1
174
+ end
175
+ days += days_in_month(prev_month, prev_year)
176
+ end
177
+
178
+ if months.negative?
179
+ years -= 1
180
+ months += 12
181
+ end
182
+
183
+ return nil if years.negative?
184
+
185
+ { years: years, months: months, days: days }
186
+ end
187
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
188
+
189
+ def days_in_month(month, year)
190
+ Time.new(year, month, -1).day
191
+ end
192
+
193
+ def log_result(result, elapsed)
194
+ if result
195
+ @log&.log('kerberos', "found principal: #{result[:principal]} (#{elapsed}ms)")
196
+ @log&.log('kerberos', "name=#{result[:display_name]} email=#{result[:email]}")
197
+ else
198
+ @log&.log('kerberos', "no kerberos ticket found (#{elapsed}ms)")
199
+ end
200
+ end
201
+ end
202
+ # rubocop:enable Metrics/ClassLength
203
+ end
204
+ end
205
+ end